Server-Side vs Client-Side API Calls in Next.js
When to fetch data on the server, when to call APIs from the browser, and how both patterns look in the App Router with real code.
Two places data can be fetched
In the Next.js App Router, components are Server Components by default. That means you can await a fetch directly in a page or layout and send HTML that already contains the data. Client Components, marked with "use client", run in the browser and fetch after the page loads or in response to user actions.
Neither approach is always better. Server fetches keep secrets safe, improve first paint, and work well for SEO. Client fetches are better for private dashboards, infinite scroll, search-as-you-type, and anything that must react to browser-only state.
Server-side API call in a Server Component
A Server Component can call an API during the request. The fetch runs on the server, so API keys and internal URLs never reach the browser. The page ships with data already rendered.
// app/posts/page.js
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
if (!res.ok) {
throw new Error("Failed to load posts");
}
return res.json();
}
export default async function PostsPage() {
const posts = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}What next.revalidate does
With next: { revalidate: 60 }, Next.js caches the response and refreshes it at most once every 60 seconds. Use cache: "no-store" when the data must be fresh on every request, such as a live account balance.
- Keep secrets in process.env and read them only on the server
- Prefer Server Components for public content and SEO pages
- Throw or call notFound() when the upstream response fails
// Always fresh on each request
const res = await fetch("https://api.example.com/me", {
cache: "no-store",
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
});Server-side call through a Route Handler
Sometimes the browser should hit your own API, while your server talks to a third-party service. Route Handlers under app/api keep tokens off the client and give you a stable endpoint for forms and client components.
// app/api/posts/route.js
export async function GET() {
const res = await fetch("https://api.example.com/posts", {
headers: {
Authorization: `Bearer ${process.env.API_TOKEN}`,
},
next: { revalidate: 60 },
});
if (!res.ok) {
return Response.json(
{ error: "Upstream failed" },
{ status: 502 }
);
}
const posts = await res.json();
return Response.json(posts);
}Client-side API call in a Client Component
Mark a component with "use client" when it needs hooks, event handlers, or browser APIs. Fetch inside useEffect for initial load, or inside an event handler for actions like search and submit.
// components/posts-list.jsx
"use client";
import { useEffect, useState } from "react";
export function PostsList() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function loadPosts() {
try {
const res = await fetch("/api/posts");
if (!res.ok) throw new Error("Request failed");
const data = await res.json();
if (!cancelled) setPosts(data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}
loadPosts();
return () => {
cancelled = true;
};
}, []);
if (loading) return <p>Loading posts...</p>;
if (error) return <p>Error: {error}</p>;
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}Client-side call on user action
Not every client fetch belongs in useEffect. Search, filters, and form submits should run when the user acts. Call your own Route Handler so secrets stay on the server.
// components/search-posts.jsx
"use client";
import { useState } from "react";
export function SearchPosts() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
async function handleSearch(event) {
event.preventDefault();
const res = await fetch(
`/api/posts/search?q=${encodeURIComponent(query)}`
);
const data = await res.json();
setResults(data);
}
return (
<form onSubmit={handleSearch}>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search posts"
/>
<button type="submit">Search</button>
<ul>
{results.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</form>
);
}Mixing both patterns
A common production shape is: fetch the first page of data on the server for a fast, SEO-friendly render, then pass that data into a Client Component for filters, pagination, or mutations.
// app/posts/page.js
import { PostsExplorer } from "@/components/posts-explorer";
async function getPosts() {
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function PostsPage() {
const initialPosts = await getPosts();
return <PostsExplorer initialPosts={initialPosts} />;
}
// components/posts-explorer.jsx
"use client";
export function PostsExplorer({ initialPosts }) {
// start with server data, then refetch from the client as needed
return <pre>{JSON.stringify(initialPosts, null, 2)}</pre>;
}How to choose
Use the server when the data is needed for the first render, when SEO matters, or when the request needs a secret. Use the client when the request depends on browser state, user interaction, or frequent local updates.
- Server Component fetch: public pages, SEO, secrets, first paint
- Route Handler + Client Component: interactive UI with protected upstream APIs
- Pure client fetch to a public API: only when no secret is involved
- Avoid putting API tokens in NEXT_PUBLIC_ env vars
