Short answer
The App Router is not a new syntax for the Pages Router. It is a different execution model: components are server-side by default, layouts persist across navigation without re-rendering, and the unit of loading is a Suspense boundary rather than a page. Most migration pain comes from carrying Pages Router habits into it.
The file conventions are easy to learn and easy to misuse. What follows is the set of patterns that hold up on real projects, and the specific mistakes that cause the most rework.
The file conventions that matter
| File | Purpose | Key property |
|---|---|---|
layout.tsx |
Shared shell around a segment | Persists across navigation; state survives |
template.tsx |
Same, but remounts every navigation | Use when you need enter animations or reset |
page.tsx |
The route’s own UI | Receives params and searchParams |
loading.tsx |
Suspense fallback for the segment | Shown while the segment streams |
error.tsx |
Error boundary | Must be a client component |
not-found.tsx |
404 UI for the segment | Triggered by notFound() |
The distinction between layout and template is the one people get wrong. A layout does not re-render when you navigate between its children — which is why a sidebar keeps its scroll position, and also why an animation defined there plays once and never again.
Layouts do not re-render, and that has consequences
Because a layout persists, it cannot read the current route’s searchParams. This surprises people who want a layout-level header reflecting a filter. The options are to read the params in the page and pass them down, or to use a small client component in the layout that calls useSearchParams.
Route groups let you have several layouts without changing URLs. A folder in parentheses organises files and contributes nothing to the path:
app/
(marketing)/ # /about, /pricing - wide marketing shell
layout.tsx
about/page.tsx
(app)/ # /dashboard - authenticated shell with sidebar
layout.tsx
dashboard/page.tsx
Both groups sit at the root, so the URLs are /about and /dashboard, with completely different chrome and no nesting hack.
Streaming is the point of loading.tsx
A loading.tsx wraps the segment in a Suspense boundary. The shell is sent immediately and the slow part streams in when ready — the user sees layout and navigation in a few hundred milliseconds instead of a blank page for two seconds.
The finer-grained version is explicit boundaries around individual slow components, which is almost always better than one page-level spinner:
export default async function Page() {
// Fast: awaited, blocks the response. Correct for the primary content.
const article = await getArticle();
return (
<article>
<ArticleBody data={article} />
{/* Slow and non-critical: streams in without delaying the article. */}
<Suspense fallback={<RelatedSkeleton />}>
<RelatedArticles id={article.id} />
</Suspense>
</article>
);
}
The judgement call is what belongs above the fold and must be awaited, versus what can arrive late. Putting everything in Suspense produces a page that flashes skeletons everywhere; putting nothing in it produces a slow first byte.
Avoid the server-side waterfall
Sequential awaits in a server component are a waterfall you cannot see in the network tab, because it all happens before the response starts:
// Three round trips in series - total latency is the sum.
const user = await getUser(id);
const posts = await getPosts(id);
const stats = await getStats(id);
// One round trip's worth of latency for all three.
const [user, posts, stats] = await Promise.all([
getUser(id), getPosts(id), getStats(id),
]);
Only serialise when there is a genuine dependency — when the second call needs a value from the first.
Server actions
A function marked 'use server' can be called from a client component and executes on the server. It replaces most hand-written API routes for first-party mutations.
'use server';
export async function subscribe(formData: FormData) {
// Treat every argument as untrusted: this is a public HTTP endpoint.
const parsed = SubscribeSchema.safeParse({ email: formData.get('email') });
if (!parsed.success) return { ok: false, error: 'Invalid email' };
await db.subscriber.create({ data: parsed.data });
revalidatePath('/subscribers');
return { ok: true };
}
Two rules that are not optional. Validate every input — a server action is reachable by anyone who can view the page, exactly like a REST endpoint. And check authorisation inside the action; hiding the button that calls it is not access control.
Parallel and intercepting routes
Parallel routes (@slot folders) render several independent segments into one layout, each with its own loading and error state — genuinely useful for dashboards where one panel failing should not blank the page.
Intercepting routes ((.)folder) render a route in the current layout instead of navigating, which is how you build a modal that has a real URL: sharable and directly loadable, but presented as an overlay when reached from within the app. Both are powerful and both are easy to over-apply — reach for them when the requirement is genuinely there.
Migrating from the Pages Router
- Both routers can coexist. Migrate route by route rather than in one change.
getServerSidePropsandgetStaticPropshave no equivalent — fetch directly in the component and control freshness through caching options instead.useRoutermoves tonext/navigationand its API differs;router.querysplits intouseParamsanduseSearchParams._appand_documentbecome the root layout.- Data fetching libraries that assume a client render need
'use client'and a provider — wrap the smallest subtree, not the root.
The caching model is the part that most often produces surprising behaviour after a migration, and it is covered separately in Next.js Caching Explained. The boundary rules that decide what ships to the browser are in React Server Components.
Questions people actually ask
- Should I migrate an existing Pages Router app?
- Only with a reason – a genuine need for streaming, server components or nested layouts. The Pages Router is still supported and a working application is worth something. If you do migrate, do it incrementally: both routers run side by side, so you can move one route at a time and stop if the benefit is not there.
- Why does my layout not update when the URL changes?
- Because layouts persist by design and do not re-render across navigations between their children. If you need per-navigation behaviour, use
template.tsxinstead, or read the changing value in the page and pass it down. - Are server actions a replacement for API routes?
- For first-party mutations called from your own UI, largely yes – they remove a lot of boilerplate. You still want route handlers for anything with an external consumer: webhooks, public APIs, or endpoints called by a mobile client, where an explicit HTTP contract matters.
- How do I show a loading state for just one part of a page?
- Wrap that component in its own
<Suspense>with a fallback, rather than relying on segment-levelloading.tsx. That lets the rest of the page render immediately while the slow section streams in, which is nearly always the better experience. - Is searchParams available in a layout?
- No. Layouts do not re-render on navigation, so they cannot receive them. Read them in the page and pass down, or use a client component calling
useSearchParamsfor the small part of the layout that needs to react.