Skip to content

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

src/app/(dashboard)/projects/[projectId]/page.tsx
// 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
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)


// 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