Round-Tripping Data From Server Components Through Route Handlers
A common pattern that appears in early App Router code is a Server Component that fetches its own data from a Route Handler:
app/page.tsx
export default async function Page() {
let res = await fetch('http://localhost:3000/api/data');
let data = await res.json();
return <h1>{JSON.stringify(data)}</h1>;
}
app/api/data/route.ts
export async function GET(request: Request) {
return Response.json({ data: 'Next.js' });
}
This introduces two problems. First, both the Server Component and the Route Handler execute on the server, so the extra network request is pure overhead — you can call the underlying logic directly. Second, because this runs under Node.js, the fetch needs an absolute URL rather than a relative one, which typically forces you to write environment-specific boilerplate.
Prefer direct invocation instead:
app/page.tsx
export default async function Page() {
// call your async function directly
let data = await getData(); // { data: 'Next.js' }
// or call an external API directly
let data = await fetch('https://api.vercel.app/blog')
// ...
}
Route Handlers Are Cached by Default
Developers coming from the Pages Router often expect API Routes-style behavior from Route Handlers. But a GET Route Handler is static by default — it gets prerendered at build time just like a page:
app/api/data/route.ts
export async function GET(request: Request) {
return Response.json({ data: 'Next.js' });
}
The returned JSON will not change until the next build. This is intentional: Route Handlers are the building blocks of pages and layouts, so they share the same route segment configuration and caching semantics.
That opens capabilities API Routes never had. You can generate JSON, text, or any other file type during the build, cache it, and revalidate it on a schedule if needed:
app/api/data/route.ts
export async function GET(request: Request) {
let res = await fetch('https://api.vercel.app/blog');
let data = await res.json();
return Response.json(data);
}
Static Route Handlers are also compatible with Static Exports, letting you deploy the output to any static file host.
Client Components Don’t Need Route Handlers for Mutations
Client Components can’t be async, so fetching or mutating data from them seems to require a Route Handler. But Server Actions can be called directly from Client Components — no fetch wrapper needed.
app/user-form.tsx
'use client';
import { save } from './actions';
export function UserForm() {
return (
<form action={save}>
<input type="text" name="username" />
<button>Save</button>
</form>
);
}
This works inside event handlers as well as forms:
app/user-form.tsx
'use client';
import { save } from './actions';
export function UserForm({ username }) {
async function onSave(event) {
event.preventDefault();
await save(username);
}
return <button onClick={onSave}>Save</button>;
}
Positioning Suspense Boundaries
When an async Server Component fetches data, the Suspense boundary has to sit above it in the tree.
app/page.tsx
async function BlogPosts() {
let data = await fetch('https://api.vercel.app/blog');
let posts = await data.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default function Page() {
return (
<section>
<h1>Blog Posts</h1>
<BlogPosts />
</section>
);
}
Placing the boundary inside the async component itself won’t work — the fallback must wrap the component doing the fetching:
app/page.tsx
import { Suspense } from 'react';
async function BlogPosts() {
let data = await fetch('https://api.vercel.app/blog');
let posts = await data.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
export default function Page() {
return (
<section>
<h1>Blog Posts</h1>
<Suspense fallback={<p>Loading...</p>}>
<BlogPosts />
</Suspense>
</section>
);
}
This composition will become more prominent with Partial Prerendering, where you decide upfront which components prerender and which run on demand:
import { unstable_noStore as noStore } from 'next/cache';
async function BlogPosts() {
noStore(); // This component should run dynamically
let data = await fetch('https://api.vercel.app/blog');
let posts = await data.json();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Accessing the Request From a Server Component
Server Components don’t receive the request object, which can push developers toward client hooks like useSearchParams. But the request is reachable through dedicated functions and props:
cookies()headers()paramssearchParams
app/blog/[slug]/page.tsx
export default function Page({
params,
searchParams,
}: {
params: { slug: string }
searchParams: { [key: string]: string | string[] | undefined }
}) {
return <h1>My Page</h1>
}
Context Providers and the Client Boundary
React Context doesn’t work in Server Components, which leads to two recurring issues: attempting to use context server-side, and misplacing the provider in the tree. The fix is to define the provider as its own Client Component that takes children and renders them:
app/theme-provider.tsx
'use client';
import { createContext } from 'react';
export const ThemeContext = createContext({});
export default function ThemeProvider({
children,
}: {
children: React.ReactNode;
}) {
return <ThemeContext.Provider value="dark">{children}</ThemeContext.Provider>;
}
That lets you import the provider into your root layout while keeping everything below it server-rendered:
app/layout.tsx
import ThemeProvider from './theme-provider';
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
);
}
With the provider at the root, every Client Component in the app can consume the context, and Server Components — including the page itself — can still sit lower in the tree.
Composing Server and Client Components
Server Components unlock direct data fetching, but they forfeit client-side React features. A counter button, for example, must live in its own Client Component marked "use client":
app/counter.tsx
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
That component can then be imported into a Server Component page:
app/page.tsx
import { Counter } from './counter';
export default function Page() {
return (
<section>
<h1>My Page</h1>
<Counter />
</section>
);
}
What about children of that Client Component? They can still be Server Components via composition:
app/page.tsx
import { Counter } from './counter';
function Message() {
return <p>This is a Server Component</p>;
}
export default function Page() {
return (
<section>
<h1>My Page</h1>
<Counter>
<Message />
</Counter>
</section>
);
}
app/counter.tsx
'use client';
import { useState } from 'react';
export function Counter({ children }: { children: React.ReactNode }) {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
{children}
</div>
);
}
Don’t Spray “use client” Everywhere
Adding "use client" puts you inside the client boundary — siblings of that component are already client-side. Files further down that import it don’t need the directive again. Client Components are still prerendered on the server, like Pages Router components.
The "use client" directive is mainly useful for incremental App Router adoption: marking a high-level component as a client boundary lets you weave Server Components in further down the tree.
Revalidating After Mutations
The App Router’s data model separates fetching from revalidating. A Server Action that inserts into a Postgres database won’t automatically refresh the UI:
app/page.tsx
export default function Page() {
async function create(formData: FormData) {
'use server';
let name = formData.get('name');
await sql`INSERT INTO users (name) VALUES (${name})`;
}
return (
<form action={create}>
<input name="name" type="text" />
<button type="submit">Create</button>
</form>
);
}
After the insert succeeds, the list on the page is stale until you explicitly tell Next.js to revalidate:
app/page.tsx
import { revalidatePath } from 'next/cache';
export default async function Page() {
let names = await sql`SELECT * FROM users`;
async function create(formData: FormData) {
'use server';
let name = formData.get('name');
await sql`INSERT INTO users (name) VALUES (${name})`;
revalidatePath('/');
}
return (
<section>
<form action={create}>
<input name="name" type="text" />
<button type="submit">Create</button>
</form>
<ul>
{names.map((name) => (
<li>{name}</li>
))}
</ul>
</section>
);
}
Redirects vs. try/catch
The redirect() function returns TypeScript’s never type, so no return statement is needed — internally it throws a Next.js-specific error. That means redirects must happen outside try/catch blocks, or the error will be caught and swallowed.
From a Server Component, redirect directly:
app/page.tsx
import { redirect } from 'next/navigation';
async function fetchTeam(id) {
const res = await fetch('https://...');
if (!res.ok) return undefined;
return res.json();
}
export default async function Profile({ params }) {
const team = await fetchTeam(params.id);
if (!team) {
redirect('/login');
}
// ...
}
From a Client Component, the redirect belongs inside a Server Action, not in an event handler:
app/client-redirect.tsx
'use client';
import { navigate } from './actions';
export function ClientRedirect() {
return (
<form action={navigate}>
<input type="text" name="id" />
<button>Submit</button>
</form>
);
}
app/actions.ts
'use server';
import { redirect } from 'next/navigation';
export async function navigate(data: FormData) {
redirect('/posts');
}



