Next.js — App Router¶
OWNER: floris, floor
ALSO_USED_BY: urszula, maxim, alexander
LAST_VERIFIED: 2026-03-26
GE_STACK_VERSION: next@15.5.x
Overview¶
App Router is the routing layer for all GE projects. Folder-based routing under src/app/.
Every folder = route segment. Special files define behavior per segment.
Agents must understand layouts, route groups, parallel routes, and intercepting routes.
Special Files Reference¶
| File | Purpose | Renders |
|---|---|---|
layout.tsx |
Persistent wrapper, does NOT remount on navigation | Server |
page.tsx |
Unique UI for this route segment | Server |
loading.tsx |
Instant loading state (wraps page in Suspense) | Server |
error.tsx |
Error boundary for this segment + children | Client |
not-found.tsx |
404 for this segment | Server |
template.tsx |
Like layout but REMOUNTS on every navigation | Server |
default.tsx |
Fallback for parallel routes when no match | Server |
route.ts |
API endpoint (GET, POST, etc.) | Server |
CHECK: every route segment has page.tsx
CHECK: error.tsx has "use client" directive (required by React)
CHECK: layout.tsx does NOT fetch user-specific data (shared across navigations)
Root Layout (Required)¶
// src/app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: { template: "%s | AppName", default: "AppName" },
description: "...",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="nl" suppressHydrationWarning>
<body className={inter.className}>{children}</body>
</html>
);
}
CHECK: <html lang="nl"> for Dutch clients (or lang from i18n config)
CHECK: suppressHydrationWarning on <html> only (for theme/class injection)
ANTI_PATTERN: suppressHydrationWarning on arbitrary elements to hide bugs
FIX: find and fix the actual hydration mismatch source
Route Groups¶
Route groups organize routes without affecting the URL. Use parentheses: (groupName).
src/app/
├── (public)/ # No auth required
│ ├── page.tsx # / (home)
│ ├── pricing/page.tsx # /pricing
│ └── layout.tsx # Public layout (no sidebar)
├── (dashboard)/ # Auth required
│ ├── projects/page.tsx # /projects
│ ├── settings/page.tsx # /settings
│ └── layout.tsx # Dashboard layout (sidebar + nav)
└── layout.tsx # Root layout
GE_CONVENTION: always use route groups to separate authenticated vs public
GE_CONVENTION: dashboard layout imports auth check — redirect to login if unauthenticated
GE_CONVENTION: never nest route groups more than one level deep
Dynamic Routes¶
// Typed params (Next.js 15.5+)
type Props = {
params: Promise<{ projectId: string }>;
};
export default async function ProjectPage({ params }: Props) {
const { projectId } = await params;
// fetch project data...
}
CHECK: params is awaited (Next.js 15 changed params to async)
IF: params is used synchronously
THEN: build will warn, future versions will error
Dynamic Route Variants¶
| Pattern | Example | Matches |
|---|---|---|
[slug] |
/blog/[slug] |
/blog/hello → { slug: "hello" } |
[...slug] |
/docs/[...slug] |
/docs/a/b/c → { slug: ["a","b","c"] } |
[[...slug]] |
/shop/[[...slug]] |
/shop OR /shop/a/b |
GE_CONVENTION: use descriptive param names ([projectId] not [id])
GE_CONVENTION: validate dynamic params with Zod at the top of page components
import { z } from "zod";
import { notFound } from "next/navigation";
const ParamsSchema = z.object({
projectId: z.string().uuid(),
});
export default async function ProjectPage({ params }: Props) {
const { projectId } = await params;
const parsed = ParamsSchema.safeParse({ projectId });
if (!parsed.success) notFound();
// safe to use parsed.data.projectId
}
Parallel Routes¶
Parallel routes render multiple pages in the same layout simultaneously.
Defined with @slotName convention.
src/app/(dashboard)/
├── @sidebar/
│ ├── page.tsx # Sidebar content
│ └── default.tsx # Fallback when no match
├── @main/
│ ├── page.tsx # Main content
│ └── projects/page.tsx # Main content for /projects
├── layout.tsx # Receives both slots
└── page.tsx # Not needed if slots cover it
// layout.tsx
export default function DashboardLayout({
sidebar,
main,
}: {
sidebar: React.ReactNode;
main: React.ReactNode;
}) {
return (
<div className="flex">
<aside className="w-64">{sidebar}</aside>
<main className="flex-1">{main}</main>
</div>
);
}
CHECK: every parallel route slot has default.tsx
IF: default.tsx is missing
THEN: navigating to a sub-route and back causes 404 for that slot
ANTI_PATTERN: using parallel routes for simple sidebar/header layouts
FIX: use regular layout.tsx — parallel routes are for independently navigable panels
Intercepting Routes¶
Intercept a route to show it in a different context (e.g., modal overlay while keeping URL).
| Convention | Intercepts |
|---|---|
(.)slug |
Same level |
(..)slug |
One level up |
(..)(..)slug |
Two levels up |
(...)slug |
From root |
Modal Pattern (GE Standard)¶
src/app/(dashboard)/projects/
├── page.tsx # Project list
├── [projectId]/
│ └── page.tsx # Full project page (direct nav)
└── @modal/
└── (..)projects/[projectId]/
└── page.tsx # Project as modal (intercepted)
// @modal/(..)projects/[projectId]/page.tsx
import { Modal } from "@/components/ui/modal";
export default async function ProjectModal({ params }: Props) {
const { projectId } = await params;
return (
<Modal>
<ProjectDetail projectId={projectId} />
</Modal>
);
}
IF: user clicks project link from list → modal opens (intercepted)
IF: user navigates directly to URL → full page renders (not intercepted)
IF: user refreshes while modal is open → full page renders
GE_CONVENTION: use intercepting routes for detail views opened from lists
GE_CONVENTION: the intercepted and full page share the same data-fetching component
Route Handlers (API Routes)¶
// src/app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
// process webhook...
return NextResponse.json({ received: true });
}
GE_CONVENTION: API routes only for:
1. Webhook receivers (Stripe, external services)
2. External API consumption (third-party integrations)
3. File uploads/downloads
IF: internal form submission or data mutation
THEN: use Server Actions, NOT API routes
READ_ALSO: wiki/docs/stack/nextjs/data-fetching.md
CHECK: API routes validate input with Zod
CHECK: API routes return proper HTTP status codes
CHECK: webhook routes verify signatures (Stripe, GitHub, etc.)
Metadata & SEO¶
// Static metadata
export const metadata: Metadata = {
title: "Projects",
description: "Manage your projects",
};
// Dynamic metadata
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { projectId } = await params;
const project = await getProject(projectId);
return {
title: project.name,
description: project.description,
openGraph: { title: project.name, images: [project.coverImage] },
};
}
CHECK: every page has metadata (static or dynamic)
CHECK: root layout has default metadata with title.template
ANTI_PATTERN: using <Head> from next/head (Pages Router pattern)
FIX: use metadata export or generateMetadata() function
Static Generation with generateStaticParams¶
export async function generateStaticParams() {
const projects = await getAllProjectSlugs();
return projects.map((slug) => ({ slug }));
}
IF: page uses dynamic route + data is known at build time
THEN: add generateStaticParams to pre-render pages
CHECK: dynamicParams is set correctly
IF: dynamicParams = false → unknown params return 404
IF: dynamicParams = true (default) → unknown params render on-demand
Route Segment Config¶
// Per-route configuration exports
export const dynamic = "force-dynamic"; // never cache
export const revalidate = 3600; // ISR: revalidate every hour
export const fetchCache = "default-cache"; // opt into caching
export const runtime = "nodejs"; // always nodejs for GE (not edge)
export const preferredRegion = "eu-west-1"; // EU data sovereignty
GE_CONVENTION: runtime is always "nodejs" — never use edge runtime
GE_CONVENTION: preferredRegion is always EU (when deploying to cloud)
Navigation¶
// Server Component — use Link
import Link from "next/link";
<Link href="/projects" prefetch={true}>Projects</Link>
// Client Component — programmatic navigation
"use client";
import { useRouter } from "next/navigation";
const router = useRouter();
router.push("/projects");
router.replace("/login");
router.refresh(); // revalidate current route server data
ANTI_PATTERN: importing useRouter from next/router (Pages Router)
FIX: import from next/navigation
ANTI_PATTERN: using <a> tags for internal navigation
FIX: use <Link> — enables client-side navigation + prefetching
CHECK: prefetch is explicit on critical navigation links
CHECK: router.refresh() is called after mutations to update server data
Cross-References¶
READ_ALSO: wiki/docs/stack/nextjs/index.md
READ_ALSO: wiki/docs/stack/nextjs/server-components.md
READ_ALSO: wiki/docs/stack/nextjs/data-fetching.md
READ_ALSO: wiki/docs/stack/nextjs/middleware.md
READ_ALSO: wiki/docs/stack/nextjs/pitfalls.md