App Router routing cheatsheet.
Dynamic segment
app/posts/[id]/page.tsx → /posts/:id
async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <p>{id}</p>;
}
Catch-all
app/docs/[...slug]/page.tsx → /docs/a, /docs/a/b, /docs/a/b/c
async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params; // ["a", "b", "c"]
}
Optional catch-all
app/docs/[[...slug]]/page.tsx → /docs, /docs/a, /docs/a/b
slug may be undefined.
Route groups (parens, not in URL)
app/(marketing)/about/page.tsx → /about
app/(app)/dashboard/page.tsx → /dashboard
Group layouts without affecting URL:
app/(marketing)/layout.tsx # wraps marketing pages only
app/(app)/layout.tsx # wraps app pages only
Parallel routes (@slots)
app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/page.tsx
└── @team/page.tsx
// layout.tsx
export default function Layout({ children, analytics, team }: {
children: React.ReactNode;
analytics: React.ReactNode;
team: React.ReactNode;
}) {
return (
<div>
{children}
{analytics}
{team}
</div>
);
}
Each slot has its own loading/error states.
Intercepted routes
app/feed/page.tsx
app/feed/(.)photo/[id]/page.tsx # intercepts /photo/:id when navigating from /feed
app/photo/[id]/page.tsx # full route when directly visited
(.): same level. (..): one level up. (...): from root.
Common use: open photo as modal in feed; full page on direct visit.
Default page
app/dashboard/@team/default.tsx
Renders when slot doesn’t match (parallel routes).
generateStaticParams
// app/posts/[id]/page.tsx
export async function generateStaticParams() {
const posts = await db.post.findMany();
return posts.map((p) => ({ id: String(p.id) }));
}
async function Page({ params }) {
const { id } = await params;
...
}
Pre-renders the pages at build time (SSG).
dynamic = “force-static” | “force-dynamic”
export const dynamic = "force-static";
// All requests rendered at build time. No dynamic features.
export const dynamic = "force-dynamic";
// All requests rendered on demand.
export const dynamic = "auto"; // default — Next decides
dynamicParams
export const dynamicParams = false;
// 404 for params not in generateStaticParams
revalidate
export const revalidate = 60;
// ISR: regenerate page every 60s
fetchCache
export const fetchCache = "force-cache" | "default-cache" | "default-no-store" | "force-no-store";
Default fetch caching for the route.
metadata
// Static
export const metadata = { title: "Page" };
// Dynamic
export async function generateMetadata({ params }) {
const { id } = await params;
const post = await fetchPost(id);
return { title: post.title };
}
redirect / notFound at route level
async function Page({ params }) {
const post = await db.post.find(params.id);
if (!post) notFound(); // shows nearest not-found.tsx
return <Article post={post} />;
}
Search params
async function Page({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
const { q } = await searchParams;
return <p>q = {q}</p>;
}
In Next 15+ searchParams is a Promise.
Middleware
// middleware.ts (root)
import { NextResponse, type NextRequest } from "next/server";
export function middleware(req: NextRequest) {
if (!req.cookies.has("session")) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
Common mistakes
- Forgetting
await paramsin Next 15+ — TypeError. - Confusing route groups (
()) with dynamic segments ([]). - Multiple
[id]segments at the same level — conflict. force-dynamicon a static page — kills caching.- Catch-all
[...slug]blocking sibling routes.
Read this next
If you want my routing patterns + middleware setup, they’re at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .