The App Router: File-System Routing & The Special File Matrix19 min read
Module F-2·19 min read
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.
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.
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.
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.
Next.js gives the app/ directory a fixed vocabulary of file names. Each name does exactly one thing. There are eleven of them, and understanding all eleven 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
forbidden.tsx
403 boundary — shown when forbidden() is called (Next.js 15)
unauthorized.tsx
401 boundary — shown when unauthorized() is called (Next.js 15)
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
forbidden.tsx/unauthorized.tsx work exactly like not-found.tsx — a boundary component that renders when the matching function (forbidden()/unauthorized(), both from next/navigation) is called anywhere in the segment. They're the two auth-specific counterparts to notFound(), and Module 13 covers them in full alongside Auth.js session handling.
Learn these eleven 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.
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:
// 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:
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.
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.
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:
// app/layout.tsx
import type { Metadata } from 'next'; import './globals.css';
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.
Layouts persist. Sometimes you explicitly want the opposite: the wrapper should reinstantiate fresh on every navigation to that segment. Enter template.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.
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.
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.
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.
// app/dashboard/error.tsx
'use client'; // ← required — error boundaries must be Client Components
'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.
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.
// 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 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).
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:
// 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.
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.
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).
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:
// 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 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.
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>:
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.
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 finds page.tsx and renders it
The page is wrapped in each layout from innermost to outermost
Any loading.tsx files become Suspense boundaries around the content below them
Any error.tsx files 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.
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.
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.
Knowledge Check
How do you create an optional catch-all route segment that matches the root path / as well as any sub-paths?
What is the primary purpose of a Route Group, created by wrapping a folder name in parentheses (e.g., (auth))?
Why is it safe to colocate non-routing files, such as UI components or utilities, directly inside the app/ directory alongside route segments?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.