The Next.js App Router: File Conventions That Do Real Work
A practical tour of the App Router — layouts, route groups, loading and error boundaries, dynamic segments and generateStaticParams — and when to use each.
Table of contents
- The special files
- Route groups organise without affecting URLs
- Dynamic segments and static generation
- loading.tsx is a Suspense boundary
- error.tsx must be a Client Component
- Metadata
- Frequently asked questions
- Should I migrate from the Pages Router?
- Why is my layout not re-rendering?
- Can route.ts and page.tsx live in the same folder?
- How do I read search params in a Server Component?
- Related reading
- References
The App Router is a filesystem router where specific filenames have specific meanings. Learning those seven filenames is most of learning the App Router.
The special files#
app/
├── layout.tsx wraps children; persists across navigation
├── page.tsx the route's UI (makes the segment routable)
├── loading.tsx Suspense fallback for this segment
├── error.tsx error boundary (must be a Client Component)
├── not-found.tsx rendered by notFound()
├── route.ts an API endpoint (cannot coexist with page.tsx)
└── template.tsx like layout, but remounts on every navigationThe important property of layout.tsx is that it does not re-render on navigation between its children. State in a layout survives — which is what makes a persistent sidebar or an audio player possible, and why a layout is the wrong place for anything that must reflect the current page.
Route groups organise without affecting URLs#
A folder in parentheses groups routes without adding a path segment:
app/
├── (site)/
│ ├── layout.tsx header + footer
│ ├── page.tsx → /
│ └── tools/page.tsx → /tools
└── admin/
├── layout.tsx completely different chrome
└── page.tsx → /admin/tools stays /tools. This is the correct way to give one part of an app a different layout — the alternative, conditionally rendering the header based on usePathname(), forces the layout to be a Client Component and re-runs on every navigation.
Dynamic segments and static generation#
app/blog/[slug]/page.tsx → /blog/anything
app/shop/[...path]/page.tsx → /shop/a/b/c (catch-all)
app/docs/[[...path]]/page.tsx → /docs and /docs/a/b (optional catch-all)generateStaticParams pre-renders them at build time:
export function generateStaticParams() {
return getAllPostSlugs().map((slug) => ({ slug }));
}
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params; // params is a Promise in Next 15
const post = getPost(slug);
if (!post) notFound();
return <Article post={post} />;
}Two things that changed in Next 15 and catch people upgrading: params and searchParams are now Promises and must be awaited, and fetch is no longer cached by default.
Any slug not returned by generateStaticParams is still rendered on demand unless you set export const dynamicParams = false, which makes it a 404 instead.
loading.tsx is a Suspense boundary#
Dropping a loading.tsx into a segment wraps that segment in <Suspense>. The layout renders immediately and the page streams in when ready:
// app/blog/loading.tsx
export default function Loading() {
return <PostGridSkeleton />;
}For finer control, use <Suspense> directly around the slow part so the rest of the page is not held back by it.
error.tsx must be a Client Component#
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<p>Something went wrong.</p>
<button onClick={reset}>Try again</button>
</div>
);
}It has to be a Client Component because reset is an interactive handler. Note that error.tsx catches errors in its own segment's children, not in its sibling layout — for that you need the parent segment's boundary.
Metadata#
export const metadata: Metadata = { title: 'Tools' };
// or, when it depends on the route:
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
return { title: getTool(slug)?.seoTitle };
}Set metadataBase once in the root layout, and every nested route can then express canonical and OG image paths as root-relative strings.
Frequently asked questions#
Should I migrate from the Pages Router?#
For a new project, yes — App Router is where the platform work is happening. For an existing app, both routers run side by side, so migrate route by route rather than in one go.
Why is my layout not re-rendering?#
By design. Layouts persist across navigations between their children. Use template.tsx if you genuinely need a remount, or read the pathname in a Client Component.
Can route.ts and page.tsx live in the same folder?#
No — they both claim the same URL. Put API routes under app/api/ to keep them clearly separate.
How do I read search params in a Server Component?#
Via the searchParams prop, awaited. Note that using it opts the route into dynamic rendering, since the values are not known at build time.