Every special file in the app/ directory — page, layout, template, loading, error, not-found, default, forbidden, unauthorized — and exactly what each one does and when to reach for it.
F-2 — The App Router: File-System Routing & The Special File Matrix
Who this is for: Developers who completed F-1 and understand the rendering spectrum conceptually, but haven't yet written App Router code. This module is the practical foundation: by the end you'll understand exactly what every file in your app/ directory does, why the naming convention matters, and how the router composes nested layouts without you writing a single line of router configuration.
Why File-System Routing Exists
Every React application that grew past a single page has eventually arrived at the same moment: someone had to choose a router, read its documentation, decide on a pattern for nesting routes, figure out where to put protected routes, and then explain the architecture to every new engineer who joined the team.
React Router, TanStack Router, Wouter — they're all excellent libraries. They also all require you to make a set of decisions that turn out to be the same decisions every team makes, slightly differently, and then documents inconsistently.
File-system routing is the answer to that observation. The structure of your codebase is the documentation. When you open app/dashboard/settings/profile/page.tsx, you already know the URL without reading any router config. When you open app/dashboard/layout.tsx, you already know it wraps every page inside /dashboard. The conventions are load-bearing — they eliminate a category of architectural decision that was never worth making team-by-team.
The App Router takes this further than any previous React routing system. It's not just about mapping files to URLs. It's about collocating every concern — rendering behavior, data fetching, loading states, error boundaries, metadata — with the route segment it belongs to.
The Mental Model: Route Segments
Before touching any file names, get this model locked in: the app/ directory is a tree of route segments, and each folder in that tree is one segment.
app/
├── page.tsx ← renders /
├── about/
│ └── page.tsx ← renders /about
├── blog/
│ ├── page.tsx ← renders /blog
│ └── [slug]/
│ └── page.tsx ← renders /blog/:slug
└── dashboard/
├── layout.tsx ← wraps /dashboard and everything under it
├── page.tsx ← renders /dashboard
└── settings/
└── page.tsx ← renders /dashboard/settings
Folders define segments. Files define what happens at each segment. The page.tsx file is what makes a segment publicly accessible — a folder with no page.tsx is still a valid segment for layout and organisational purposes, but it renders nothing at that URL.
This distinction matters. You can have a folder that organises your files without creating a URL:
app/
└── (marketing)/ ← route group — no URL segment
├── about/
│ └── page.tsx ← still renders /about, not /(marketing)/about
└── contact/
└── page.tsx ← renders /contact
That (marketing) folder with parentheses is a route group — it exists for your filesystem organisation but contributes nothing to the URL. You'll use these constantly for large applications to keep related pages together without polluting URL structure.
The Special File Matrix
Next.js gives the app/ directory a fixed vocabulary of file names. Each name does exactly one thing. There are nine of them, and understanding all nine is understanding the App Router:
| File | Purpose |
|---|---|
page.tsx | Makes the segment publicly accessible at its URL |
layout.tsx | Persistent wrapper — survives navigation within the segment |
template.tsx | Re-mounting wrapper — reinstantiates on every navigation |
loading.tsx | Suspense boundary — shown while the segment's async content loads |
error.tsx | Error boundary — shown when anything in the segment throws |
not-found.tsx | 404 boundary — shown when notFound() is called or no route matches |
default.tsx | Parallel route fallback — shown when no slot has a matching segment |
route.ts | API endpoint — handles HTTP verbs, no UI rendered |
middleware.ts | Edge function — runs before every matched request |
Learn these nine names. They're the entire API surface for controlling what happens at each segment of your application. Everything else in Next.js is built on top of these.
page.tsx — The Public Face
A page.tsx (or .jsx, .js — TypeScript .tsx is the standard) is a React component that becomes the content for its URL. It's a Server Component by default:
tsx// app/blog/page.tsx — renders at /blog export default function BlogIndex() { return <h1>All Posts</h1>; }
Pages receive two props that Next.js injects automatically:
tsx// app/blog/[slug]/page.tsx interface PageProps { params: Promise<{ slug: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } export default async function BlogPost({ params, searchParams }: PageProps) { const { slug } = await params; const { ref } = await searchParams; // ... }
The params and searchParams props are Promises in Next.js 15 and later — this is a React 19 change. If you see code that destructures them synchronously without await, that's Next.js 14 code. It still works (Next.js 15 supports both patterns during the transition), but new code should await them.
params contains the dynamic segment values for that route. searchParams contains the query string at the time of the request — but only for dynamic pages; statically generated pages receive an empty object.
layout.tsx — The Persistent Shell
A layout wraps all pages and nested layouts in its segment. It persists across navigations within that segment — its component state is preserved, it doesn't unmount and remount when a user navigates from /dashboard to /dashboard/settings.
tsx// app/dashboard/layout.tsx export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { return ( <div className="flex h-screen"> <DashboardSidebar /> <main className="flex-1 overflow-auto">{children}</main> </div> ); }
The children prop is where the matched child page (or nested layout) gets inserted. You never import children or specify what it is — Next.js handles the composition automatically.
Layouts nest. If you have app/layout.tsx, app/dashboard/layout.tsx, and app/dashboard/settings/layout.tsx, all three wrap the settings page in order — the root layout outermost, the settings layout innermost, with the page inside it.
Critical rule: the root layout at app/layout.tsx must render <html> and <body> tags. Next.js does not inject them automatically. This is the one file in your entire application that owns the HTML document shell:
tsx// app/layout.tsx import type { Metadata } from 'next'; import './globals.css'; export const metadata: Metadata = { title: { template: '%s | My App', default: 'My App' }, description: 'Built with Next.js', }; export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body>{children}</body> </html> ); }
The metadata export here is worth noting. Setting title as an object with template and default means child pages that export their own title will have it automatically formatted as "Blog Post Title | My App". Pages that don't export a title get "My App". You get a consistent title pattern across the entire site with zero prop drilling.
template.tsx — When You Actually Need Remounting
Layouts persist. Sometimes you explicitly want the opposite: the wrapper should reinstantiate fresh on every navigation to that segment. Enter template.tsx.
tsx// app/products/template.tsx — remounts on every navigation 'use client'; import { useEffect } from 'react'; import { trackPageView } from '@/lib/analytics'; export default function ProductTemplate({ children, }: { children: React.ReactNode; }) { useEffect(() => { trackPageView(); }, []); // runs on mount — which happens on every navigation because this is a template return <>{children}</>; }
Templates are the right tool when you need useEffect to fire on every navigation (analytics events, scroll-to-top behavior), when you need CSS enter/exit animations tied to the page transition lifecycle, or when you have state that must reset when navigating to a new route within the same segment.
In practice, templates are rarely needed. If you're reaching for template.tsx frequently, it's usually a sign that you have animation or analytics requirements — legitimate uses — or that you're fighting the persistent layout model when you should be working with it.
loading.tsx — The Built-In Suspense Boundary
This is one of the best quality-of-life features in the App Router. Any async work in a page — database queries, fetch calls, anything awaited — can block rendering. loading.tsx wraps your page in a Suspense boundary automatically, so users see your loading UI while the data loads instead of a blank screen.
tsx// app/blog/loading.tsx export default function BlogLoading() { return ( <div className="space-y-4"> {[...Array(5)].map((_, i) => ( <div key={i} className="h-24 animate-pulse rounded-lg bg-zinc-800" /> ))} </div> ); }
What's happening under the hood: Next.js wraps your page.tsx in <Suspense fallback={<BlogLoading />}>. When the page is async and hasn't resolved yet, React renders the fallback. When the page resolves, React streams in the real content.
You don't need to write <Suspense> anywhere. You don't need to import loading from './loading'. You just create the file and Next.js wires up the boundary.
The loading UI renders immediately — it's served as HTML from the server, so users never see a blank screen. This is the difference between a real streaming SSR experience and faking it with a client-side spinner.
error.tsx — Scoped Error Boundaries
Without error boundaries, a JavaScript error anywhere in your component tree crashes the entire page. error.tsx gives you a scoped boundary so an error in /dashboard/billing doesn't also destroy /dashboard/analytics.
tsx// app/dashboard/error.tsx 'use client'; // ← required — error boundaries must be Client Components import { useEffect } from 'react'; interface ErrorProps { error: Error & { digest?: string }; reset: () => void; } export default function DashboardError({ error, reset }: ErrorProps) { useEffect(() => { console.error(error); // reportToSentry(error); }, [error]); return ( <div className="flex flex-col items-center justify-center h-64"> <h2 className="text-xl font-semibold mb-4">Something went wrong</h2> <button onClick={reset} className="px-4 py-2 bg-zinc-800 rounded hover:bg-zinc-700" > Try again </button> </div> ); }
Two things to note:
'use client' is mandatory. Error boundaries are a React feature that uses class component lifecycle methods under the hood (componentDidCatch). They must be Client Components. Next.js will throw a build error if you forget this.
The reset function. Calling reset() retries the rendering of the error boundary's children. For transient errors — a flaky API, a momentary database connection issue — this gives users a path to recovery without a full page reload.
The error.digest property is a hash Next.js generates for server-side errors. It lets you log a reference ID for the error without leaking sensitive stack traces to the client. You can cross-reference it in your server logs.
not-found.tsx — Controlled 404s
When you call notFound() from Next.js anywhere in a Server Component, execution stops and Next.js renders the nearest not-found.tsx boundary. If no not-found.tsx exists in the segment tree, it bubbles up to the root.
tsx// app/blog/[slug]/page.tsx import { notFound } from 'next/navigation'; async function getPost(slug: string) { const post = await db.posts.findUnique({ where: { slug } }); if (!post) notFound(); // ← throws, rendering stops return post; } export default async function BlogPost({ params }: PageProps) { const { slug } = await params; const post = await getPost(slug); return <Article post={post} />; }
tsx// app/blog/not-found.tsx export default function BlogNotFound() { return ( <div className="text-center py-24"> <h2 className="text-2xl font-semibold">Post not found</h2> <p className="text-zinc-400 mt-2">This article doesn't exist or has been removed.</p> </div> ); }
The app-level not-found.tsx at app/not-found.tsx also fires for unmatched routes — URLs that don't match any segment in the tree at all. Put your general 404 page there, and use nested not-found.tsx files for section-specific copy when it matters (a blog 404 might be different from a product 404 in e-commerce).
route.ts — API Endpoints Without a Separate Backend
A route.ts (or .js) file turns any segment into an HTTP handler. No page is rendered — it's purely an API endpoint:
ts// app/api/posts/route.ts import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { const posts = await db.posts.findMany({ orderBy: { createdAt: 'desc' } }); return NextResponse.json(posts); } export async function POST(request: NextRequest) { const body = await request.json(); const post = await db.posts.create({ data: body }); return NextResponse.json(post, { status: 201 }); }
Export a function named after the HTTP verb you want to handle: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Unexported verbs return 405 Method Not Allowed automatically.
Dynamic segments work the same as pages:
ts// app/api/posts/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; const post = await db.posts.findUnique({ where: { id } }); if (!post) return NextResponse.json({ error: 'Not found' }, { status: 404 }); return NextResponse.json(post); }
One important constraint: you cannot have both a page.tsx and a route.ts in the same directory. A segment is either a UI route or an API endpoint, not both.
Dynamic Segments
Square brackets in a folder name create a dynamic segment. The folder name inside the brackets becomes the parameter key:
app/blog/[slug]/page.tsx → /blog/:slug
app/users/[userId]/posts/page.tsx → /users/:userId/posts
app/[locale]/page.tsx → /:locale (i18n root)
Catch-all segments use spread syntax:
app/docs/[...slug]/page.tsx → /docs/anything/at/all/depths
params.slug will be an array of strings: ['api', 'reference', 'authentication'] for /docs/api/reference/authentication.
Optional catch-all segments wrap the spread in double brackets:
app/[[...slug]]/page.tsx → / and /anything/at/all/depths
The difference: [...slug] requires at least one segment. [[...slug]] also matches the root path /, where params.slug will be undefined. You use optional catch-all most often for CMS-driven sites where the homepage itself is managed content.
Route Groups — Organisation Without URL Impact
Parentheses in a folder name create a route group. The folder exists for your organisation; it doesn't appear in the URL:
app/
├── (auth)/
│ ├── login/
│ │ └── page.tsx → /login
│ └── register/
│ └── page.tsx → /register
├── (marketing)/
│ ├── about/
│ │ └── page.tsx → /about
│ └── pricing/
│ └── page.tsx → /pricing
└── (app)/
├── layout.tsx ← layout for authenticated sections only
└── dashboard/
└── page.tsx → /dashboard
This pattern is foundational for separating layouts. The (app) group has its own layout.tsx that includes sidebar navigation, auth checks, and the authenticated shell. The (marketing) group uses the root layout but doesn't get the sidebar. You've cleanly separated two completely different UI contexts without inventing any URL namespacing.
Route groups can also define separate root layouts — multiple layout.tsx files at the group level, each with their own <html> and <body> tags, for applications that genuinely need different HTML shells (an admin portal vs a customer-facing storefront, for instance).
Parallel Routes — Multiple Slots in One Layout
Parallel routes let you render multiple independent pages in a single layout simultaneously. They use the @ prefix:
app/
└── dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│ └── page.tsx
└── @notifications/
└── page.tsx
tsx// app/dashboard/layout.tsx export default function DashboardLayout({ children, analytics, notifications, }: { children: React.ReactNode; analytics: React.ReactNode; notifications: React.ReactNode; }) { return ( <div className="grid grid-cols-3"> <main>{children}</main> <aside>{analytics}</aside> <aside>{notifications}</aside> </div> ); }
Each slot (@analytics, @notifications) renders independently. They can load data in parallel, have their own loading and error boundaries, and navigate independently without affecting each other.
The default.tsx file you saw in the matrix becomes important here: if a slot doesn't have a matching page for the current URL, Next.js renders its default.tsx. Without a default.tsx, navigating to a URL where the slot has no matching page throws a 404. You'll write a default.tsx that returns null for slots that should silently render nothing when unmatched:
tsx// app/dashboard/@notifications/default.tsx export default function NotificationsDefault() { return null; }
Parallel routes are covered in their own dedicated module (P-13) — this is enough to know they exist and what the @ convention means.
Intercepting Routes — The Modal Pattern
Intercepting routes let you load a route in a different context than its actual URL. The canonical example: clicking on a photo in a grid opens it in a modal overlay, but navigating directly to /photos/42 shows a full-screen photo page.
The folder naming convention uses parentheses with dots:
(.)photos/[id] ← intercepts from the same level
(..)photos/[id] ← intercepts one level up
(...)photos/[id] ← intercepts from the root
This is a more advanced topic covered in P-14. Mentioning it here because you'll encounter the folder names before you need to understand the full mechanics.
The Metadata API
Every page.tsx and layout.tsx can export a metadata object or a generateMetadata function. Next.js collects metadata from the entire segment tree and merges it into the document <head>:
tsx// app/blog/[slug]/page.tsx import type { Metadata } from 'next'; export async function generateMetadata({ params }: PageProps): Promise<Metadata> { const { slug } = await params; const post = await getPost(slug); return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [{ url: post.coverImageUrl }], }, twitter: { card: 'summary_large_image', }, }; }
The generateMetadata function runs on the server. It has access to params, searchParams, and can fetch data — the same data you fetch in the page component will be automatically deduped by React's cache() so you're not hitting the database twice.
Notice what you're not doing: no next/head, no <title> tags, no <meta> tags in JSX. The Metadata API is declarative — you return an object, Next.js handles the rendering. This becomes particularly powerful when layouts set base metadata and pages override only the fields they care about.
How the Router Resolves a URL
Walk through what happens when a user navigates to /dashboard/settings:
- Next.js splits the URL into segments:
['dashboard', 'settings'] - It walks the
app/directory tree, matching each segment - For each matched segment, it collects any
layout.tsx— these will wrap the final output - At the leaf segment (
settings), it findspage.tsxand renders it - The page is wrapped in each layout from innermost to outermost
- Any
loading.tsxfiles become Suspense boundaries around the content below them - Any
error.tsxfiles become error boundaries around the content below them
The result: a nested composition of layouts, loading boundaries, and error boundaries that you never assembled yourself. You just created the files; the App Router composed them.
Colocation — What Lives Next to a Route
The App Router allows you to colocate non-routing files alongside your pages. A file in app/ is only treated as a special file if its name matches the matrix — everything else is ignored by the router:
app/
└── blog/
├── page.tsx ← route
├── layout.tsx ← route
├── components/ ← colocated component folder — not a route
│ └── PostCard.tsx
└── utils.ts ← colocated utility — not a route
This is a deliberate design choice. You can keep route-specific components, utilities, and hooks right next to the pages that use them instead of scattering them in a separate top-level components/ directory. For smaller features, colocation is often the right call. For shared components used across many routes, a top-level src/components/ still makes sense.
The decision rule: if only one route segment uses it, colocate it. If two or more use it, lift it.
Where We Go From Here
You now have the complete mental model for the App Router's file system. The nine special files, the URL segment tree, dynamic routing, route groups, and parallel routes — these are the building blocks everything else in the course uses.
F-3 moves into the concept that changes everything about how you write React in Next.js: the Server Component model. You've already seen that page.tsx is a Server Component by default. F-3 unpacks exactly what that means, why it exists, and the specific mental model shift required to work with it effectively.
The most dangerous mistake you can make in the App Router is treating Server Components as "just components that happen to run on the server." They are a fundamentally different execution model with a different set of rules, capabilities, and constraints. F-3 is where you internalize that difference.