Next.js 12 Routing, Pages, Dynamic, Catch-All, and Nested Layouts
TL;DR — In Next.js 12, the URL = the file path inside
pages/.[param].tsx= dynamic segment.[...slug].tsx= catch-all.[[...slug]].tsx= optional catch-all. Layouts are component composition in_app.tsxor per-page. The app dir lands later in 2022; pages dir is still the production default.
After migrating from CRA to Next.js 12, the next thing to learn is how routing actually works. Coming from react-router, the conventions feel restrictive at first; after a week they feel like the right amount of structure.
This post covers the four routing patterns you’ll use daily, the layout patterns that pair with them, and the useRouter idioms that catch the edges.
The basics
Every .tsx file in pages/ becomes a route. Folder structure becomes URL structure.
pages/
├── index.tsx → /
├── about.tsx → /about
├── pricing.tsx → /pricing
└── docs/
├── index.tsx → /docs
└── getting-started.tsx → /docs/getting-started
Routes are case-sensitive on Linux/Vercel; Mac/Windows file systems are case-insensitive, which is a frequent source of “works on my machine” surprises. Always use lowercase route names.
index.tsx at any level handles the folder’s root URL. pages/docs/index.tsx is the /docs page; pages/docs.tsx would also work but it’s conventional to use a folder once you have multiple pages under a section.
Dynamic segments
Wrap a segment in [brackets] to make it dynamic:
pages/users/[id].tsx → /users/42, /users/abc, etc.
Read the value via useRouter:
import { useRouter } from 'next/router';
export default function UserPage() {
const router = useRouter();
const { id } = router.query;
if (!id || Array.isArray(id)) return null;
return <h1>User {id}</h1>;
}
router.query.id is string | string[] | undefined:
stringfor the typical casestring[]if you have a catch-all (see below)undefinedon the first render before client-side hydration completes
Always narrow before use. The if (!id || Array.isArray(id)) return null; pattern is common; cleaner alternative is getStaticProps / getServerSideProps, which pass the param via params argument with proper typing.
Catch-all routes
A folder with [...slug].tsx matches any depth:
pages/docs/[...slug].tsx
Matches /docs/a, /docs/a/b, /docs/a/b/c/d. router.query.slug is an array: ['a'], ['a', 'b'], ['a', 'b', 'c', 'd'].
Useful for:
- Docs sites where the URL maps to file paths
- Wiki-style pages with arbitrary nesting
- Marketing pages with category trees
export default function DocPage() {
const router = useRouter();
const slug = router.query.slug as string[] | undefined;
if (!slug) return null;
return <h1>Docs: {slug.join(' / ')}</h1>;
}
Optional catch-all
[[...slug]].tsx (double brackets) matches the parent path too:
pages/docs/[[...slug]].tsx
Matches /docs (slug = undefined), /docs/a (slug = ['a']), /docs/a/b (slug = ['a', 'b']).
Use when you want one file to handle both the section root and all sub-pages. Otherwise, prefer separate pages/docs/index.tsx + pages/docs/[...slug].tsx.
Multiple dynamic segments
pages/users/[id]/orders/[orderId].tsx
Both available in router.query:
const { id, orderId } = router.query;
Same narrowing applies.
Layouts (in pages dir)
Pre-app directory (which arrives later in 2022), layouts are component composition. Three common patterns:
Global layout in _app.tsx:
// pages/_app.tsx
import Layout from '../components/Layout';
export default function App({ Component, pageProps }) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
);
}
Every page wraps in <Layout>. Header/footer/navigation in one place.
Per-page layout opt-in:
// pages/admin/dashboard.tsx
import AdminLayout from '../../components/AdminLayout';
function Dashboard() {
return <h1>Dashboard</h1>;
}
Dashboard.getLayout = (page) => <AdminLayout>{page}</AdminLayout>;
export default Dashboard;
// pages/_app.tsx
export default function App({ Component, pageProps }) {
const getLayout = Component.getLayout || ((page) => page);
return getLayout(<Component {...pageProps} />);
}
Pages opt into a specific layout via static getLayout. Common for sections with very different chrome (admin vs marketing vs app).
Per-section layout via wrapper component:
// pages/admin/users.tsx
import AdminLayout from '../../components/AdminLayout';
export default function Users() {
return (
<AdminLayout>
<h1>Users</h1>
</AdminLayout>
);
}
Simplest, most repetitive. Fine for small sections.
Link, prefetching, scroll
import Link from 'next/link';
<Link href="/users/42"><a>View user 42</a></Link>
Three behaviours worth knowing:
- Auto-prefetch: Next.js prefetches the JS bundle for any link visible in the viewport. Hover or visible = preload. Disable with
prefetch={false}for low-priority links. - Scroll: defaults to scrolling to top on navigation.
scroll={false}keeps position (useful for tab-like UX where the URL changes but the page doesn’t). - Shallow:
shallow={true}updates URL/query without re-running data fetching. Useful for filters that update the URL but don’t need re-fetch.
For programmatic navigation:
router.push('/users/42');
router.push({ pathname: '/users/[id]', query: { id: 42 } });
router.replace('/login');
router.back();
API routes
pages/api/*.ts files become serverless endpoints. The same routing rules apply (dynamic, catch-all, etc.):
// pages/api/users/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'GET') return res.status(405).end();
const { id } = req.query;
const user = await db.users.get(id);
if (!user) return res.status(404).json({ error: 'not found' });
res.status(200).json(user);
}
These run on Node (or Edge if you configure it — runtime: 'edge' config). Useful for:
- CSRF token endpoints
- OAuth callback URLs
- Lightweight BFF (Backend For Frontend) shaping responses for the UI
- Webhooks from Stripe, GitHub, etc.
Not a substitute for a real backend if your app has substantial server-side logic. A 100-line API route is fine; a 1000-line one belongs in your real backend service.
Common Pitfalls
Trying to read router.query on first render. Without getStaticProps / getServerSideProps, the query isn’t populated until after hydration. Use router.isReady if you need to wait:
const router = useRouter();
useEffect(() => {
if (!router.isReady) return;
// safe to use router.query now
}, [router.isReady]);
Conflicts between static and dynamic routes. pages/users/profile.tsx and pages/users/[id].tsx — which handles /users/profile? The static one wins, but it’s confusing. Don’t mix.
Catch-all routes that shadow static ones. pages/docs/[...slug].tsx shadows pages/docs/getting-started.tsx. Put the catch-all in a separate folder or use the optional catch-all.
Forgetting <a> child of <Link> in Next 12.x. Will warn in dev. Required until Next.js 13.
router.push('/users/42') failing typing. TypeScript doesn’t know about your routes. Use the object form { pathname: '/users/[id]', query: { id: 42 } } if you want typed paths.
Treating _app.tsx as a place for fetching. It runs on every page navigation. Heavy work in _app slows every page. Keep it light.
API routes that hold connections open. Serverless functions are stateless and short-lived. Don’t open WebSockets or persistent DB connections at the top level — use pooled connections.
Wrapping Up
Next.js routing trades flexibility for structure: file paths replace route configs, dynamic segments are conventional, layouts compose via component patterns. Once internalized, you’ll spend zero time on routing and all your thought on the actual UI. Wednesday: data fetching with SSG, SSR, and ISR — the three strategies that determine how your pages perform.