App Router Basics
You created pages/about.js but the route doesn't exist. You added a getServerSideProps but it never runs. You're fighting the framework because you're thinking in Pages Router while writing App Router code.
The App Router flips the mental model: your folder structure is your routing. No configuration files, no route manifests. Create a folder, add a page.tsx, and you have a route. Once you internalize this, Next.js stops feeling like magic.
This lesson covers the seven special files that control how routes behave: page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, route.ts, and route groups.
Outcome
A working route tree in apps/web with nested layouts, loading states, error boundaries, an API endpoint, and a route group for organization.
Fast Track
- Create
src/app/(marketing)/about/page.tsxwith a marketing layout - Add
src/app/api/ping/route.tsreturning{ ok: true } - Add
loading.tsx,error.tsx, andnot-found.tsxto handle edge cases
The Mental Model
URL Path Folder Structure Build Output
────────────────────────────────────────────────────────────────
/ src/app/page.tsx ○ (Static)
/about src/app/about/page.tsx ○ (Static)
/products/[id] src/app/products/[id]/page.tsx ○ (Static)*
/api/ping src/app/api/ping/route.ts ƒ (Dynamic)
○ = Prerendered at build time, served instantly
ƒ = Rendered per request, always fresh
* Dynamic routes can prerender with generateStaticParams()
Every folder segment becomes a URL segment. The page.tsx file makes that segment accessible. Without page.tsx, the folder is just for organization. By default, pages prerender (○) unless they use dynamic APIs.
Special Files Reference
| File | Purpose | Renders |
|---|---|---|
page.tsx | Makes route accessible | The page content |
layout.tsx | Wraps pages, persists across navigation | Shared UI (nav, footer) |
loading.tsx | Shows during async operations | Loading skeleton |
error.tsx | Catches errors in segment | Error UI with retry |
not-found.tsx | Handles 404s | Not found message |
route.ts | API endpoint (no UI) | JSON responses |
Metadata API
Every page needs a title and description for SEO (Search Engine Optimization, helping search engines understand and rank your pages). Next.js provides the Metadata API to handle this. You export metadata directly from pages and layouts, and Next.js automatically generates the <head> tags.
Static Metadata
For pages with fixed content, export a metadata object:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn about our mission and team',
}
export default function AboutPage() {
return <div>About content</div>
}This generates:
<title>About Us</title>
<meta name="description" content="Learn about our mission and team" />Dynamic Metadata
For pages that depend on route parameters or fetched data, use generateMetadata:
import type { Metadata } from 'next'
export async function generateMetadata({
params
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const product = await fetchProduct(slug)
return {
title: product.name,
description: product.description,
}
}Next.js waits for generateMetadata to resolve before rendering the page. Both run in parallel for performance.
Metadata Inheritance
Metadata merges from root to leaf. Child values override parent values. This is useful for site-wide defaults:
export const metadata: Metadata = {
title: {
template: '%s | Next.js Foundations',
default: 'Next.js Foundations',
},
}export const metadata: Metadata = {
title: 'About Us', // Becomes "About Us | Next.js Foundations"
}The root layout sets title.template with %s as a placeholder. Child pages set title as a string, and Next.js substitutes it into the template.
You can only export metadata or generateMetadata from Server Components. Client Components cannot set metadata. If you need metadata based on client state, lift the logic to a parent Server Component.
Step 1: Create a Route Group and Layout
Route groups organize code without affecting URLs. The (marketing) folder groups related pages but /about stays /about, not /marketing/about.
Create the marketing layout:
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen">
<header className="mb-8 border-b py-4">
<nav className="flex gap-4">
<a href="/" className="font-semibold">Home</a>
<a href="/about" className="text-gray-600 hover:text-gray-900">About</a>
<a href="/pricing" className="text-gray-600 hover:text-gray-900">Pricing</a>
</nav>
</header>
<main>{children}</main>
<footer className="mt-8 border-t py-4 text-gray-500 text-sm">
© 2026 Next.js Foundations
</footer>
</div>
)
}Now create the about page:
export default function AboutPage() {
return (
<div className="max-w-2xl">
<h1 className="mb-4 font-bold text-3xl">About Us</h1>
<p className="text-gray-600">
This page uses the marketing layout. Notice the header and footer
are defined once in the layout and wrap this content automatically.
</p>
</div>
)
}Layouts nest automatically. The marketing layout wraps the about page, and the root layout wraps everything. You never manually compose them.
Step 2: Add an API Route
API routes use route.ts instead of page.tsx. They export HTTP method handlers:
import { NextResponse } from 'next/server'
export function GET() {
return NextResponse.json({ ok: true, timestamp: Date.now() })
}Test it:
curl http://localhost:3000/api/ping{"ok":true,"timestamp":1736350000000}Use route.ts for webhooks, third-party integrations, or when you need raw HTTP control. For data fetching in your own app, Server Components are usually simpler.
API routes (route.ts) are always dynamic (ƒ) because they handle HTTP requests at runtime. Unlike pages, they cannot be prerendered: each request may have different headers, body, or query parameters that affect the response.
Step 3: Add Loading State
loading.tsx automatically wraps the page in a React Suspense boundary. When the page has async operations, the loading UI shows.
export default function Loading() {
return (
<div className="animate-pulse">
<div className="mb-4 h-8 w-1/3 rounded bg-gray-200" />
<div className="mb-2 h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-2/3 rounded bg-gray-200" />
</div>
)
}To see it in action, add a delay to the about page:
// Simulate slow data fetch
async function getAboutData() {
await new Promise(resolve => setTimeout(resolve, 2000))
return { founded: 2026, team: 'Distributed' }
}
export default async function AboutPage() {
const data = await getAboutData()
return (
<div className="max-w-2xl">
<h1 className="mb-4 font-bold text-3xl">About Us</h1>
<p className="mb-4 text-gray-600">
This page uses the marketing layout. Notice the header and footer
are defined once in the layout and wrap this content automatically.
</p>
<p className="text-gray-500 text-sm">
Founded: {data.founded} · Team: {data.team}
</p>
</div>
)
}Navigate to /about and you'll see the skeleton for 2 seconds before the content appears.
Step 4: Add Error Boundary
error.tsx catches errors in its segment and children. It must be a Client Component because it uses React's error boundary API:
'use client'
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="rounded border border-red-200 bg-red-50 p-4">
<h2 className="mb-2 font-semibold text-lg text-red-800">
Something went wrong
</h2>
<p className="mb-4 text-red-600 text-sm">{error.message}</p>
<button
type="button"
onClick={reset}
className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
>
Try again
</button>
</div>
)
}To test it, temporarily throw an error in a page:
export default async function AboutPage() {
// Uncomment to test error boundary
// throw new Error('Test error boundary')
return (
// ... rest of component
)
}Step 5: Add Not Found Handler
not-found.tsx handles 404s. You can trigger it programmatically with notFound():
import Link from 'next/link'
export default function NotFound() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center">
<h1 className="mb-4 font-bold text-4xl">404</h1>
<p className="mb-4 text-gray-600">This page doesn't exist.</p>
<Link
href="/"
className="rounded bg-gray-900 px-4 py-2 text-white hover:bg-gray-800"
>
Go home
</Link>
</div>
)
}Navigate to any non-existent route like /asdfasdf to see it.
I have a Next.js 16 App Router project and I'm confused about which special files apply to my route.
**My folder structure:**
```
src/app/
___PASTE_YOUR_FOLDER_STRUCTURE___
```
Example structure:
```
src/app/
├── layout.tsx
├── loading.tsx
├── error.tsx
├── (marketing)/
│ ├── layout.tsx
│ └── about/
│ └── page.tsx
```
**The route I'm visiting:** /_____
**My questions:**
1. Which layout files wrap this page (in order)?
2. Which loading.tsx shows during navigation?
3. Which error.tsx catches errors from this page?
4. Does my route group `(marketing)` affect the URL?
Explain the file resolution order for my specific route.File Structure
After completing this lesson, your apps/web/src/app should look like:
src/app/
├── (marketing)/
│ ├── layout.tsx # Marketing header/footer
│ ├── error.tsx # Error boundary for marketing pages
│ └── about/
│ ├── page.tsx # About page content
│ └── loading.tsx # Loading skeleton
├── api/
│ └── ping/
│ └── route.ts # Health check endpoint
├── not-found.tsx # Global 404 handler
├── layout.tsx # Root layout (already exists)
└── page.tsx # Home page (already exists)
Try It
-
Test the layout: Navigate to
http://localhost:3000/about. You should see the marketing header/footer wrapping the about content. -
Test the API: Run
curl http://localhost:3000/api/pingand verify you get:{"ok":true,"timestamp":1736350000000} -
Test loading state: The about page has a 2-second delay. Refresh
/aboutand watch the skeleton appear. -
Test 404: Navigate to
http://localhost:3000/does-not-existand verify the not-found page renders. -
Observe build output: Run
pnpm buildfromapps/weband look at the route table:pnpm build --filter=@repo/webYou should see output like:
Route (app) Size First Load JS ┌ ○ / 5.2 kB 89.2 kB ├ ○ /_not-found 140 B 85.1 kB ├ ○ /about 1.2 kB 85.2 kB └ ƒ /api/ping 0 B 0 B ○ (Static) prerendered as static content ƒ (Dynamic) server-rendered on demandThe "First Load JS" column shows the total JavaScript sent to the browser when a user first visits that route, including shared framework code. Notice
/aboutis static (○) while/api/pingis dynamic (ƒ). This is your first glimpse of how Next.js decides what to cache.
Commit
git add -A
git commit -m "feat: add app router fundamentals - layouts, loading, error, api"
git pushCheck vercel list to confirm the preview deployment.
Done-When
/aboutshows marketing layout (header with Home/About/Pricing links, footer)curl localhost:3000/api/pingreturns{"ok":true,"timestamp":...}- Refreshing
/aboutshows loading skeleton for ~2 seconds /does-not-existshows the 404 page with "Go home" linkpnpm buildoutput shows/aboutas ○ (static) and/api/pingas ƒ (dynamic)
Solution
Complete file implementations
Marketing Layout
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen">
<header className="mb-8 border-b py-4">
<nav className="flex gap-4">
<a href="/" className="font-semibold">Home</a>
<a href="/about" className="text-gray-600 hover:text-gray-900">About</a>
<a href="/pricing" className="text-gray-600 hover:text-gray-900">Pricing</a>
</nav>
</header>
<main>{children}</main>
<footer className="mt-8 border-t py-4 text-gray-500 text-sm">
© 2026 Next.js Foundations
</footer>
</div>
)
}About Page with Delay
async function getAboutData() {
await new Promise(resolve => setTimeout(resolve, 2000))
return { founded: 2026, team: 'Distributed' }
}
export default async function AboutPage() {
const data = await getAboutData()
return (
<div className="max-w-2xl">
<h1 className="mb-4 font-bold text-3xl">About Us</h1>
<p className="mb-4 text-gray-600">
This page uses the marketing layout. Notice the header and footer
are defined once in the layout and wrap this content automatically.
</p>
<p className="text-gray-500 text-sm">
Founded: {data.founded} · Team: {data.team}
</p>
</div>
)
}Loading Skeleton
export default function Loading() {
return (
<div className="animate-pulse">
<div className="mb-4 h-8 w-1/3 rounded bg-gray-200" />
<div className="mb-2 h-4 w-full rounded bg-gray-200" />
<div className="h-4 w-2/3 rounded bg-gray-200" />
</div>
)
}Error Boundary
'use client'
export default function ErrorBoundary({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="rounded border border-red-200 bg-red-50 p-4">
<h2 className="mb-2 font-semibold text-lg text-red-800">
Something went wrong
</h2>
<p className="mb-4 text-red-600 text-sm">{error.message}</p>
<button
type="button"
onClick={reset}
className="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
>
Try again
</button>
</div>
)
}API Route
import { NextResponse } from 'next/server'
export function GET() {
return NextResponse.json({ ok: true, timestamp: Date.now() })
}Not Found
import Link from 'next/link'
export default function NotFound() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center">
<h1 className="mb-4 font-bold text-4xl">404</h1>
<p className="mb-4 text-gray-600">This page doesn't exist.</p>
<Link
href="/"
className="rounded bg-gray-900 px-4 py-2 text-white hover:bg-gray-800"
>
Go home
</Link>
</div>
)
}Learn More
- Layouts and Pages - Official guide to creating pages and layouts
- Route Groups - Organize routes without affecting URLs
- loading.js - Suspense boundaries with loading UI
- error.js - Error boundaries and recovery
- not-found.js - 404 handling
- route.js - API route handlers
What's Next
You now understand how folders map to routes and how special files control behavior. But notice something: every component we wrote is a Server Component by default. In the next lesson, you'll learn when and why to add 'use client', and how to think about the Server/Client boundary.
Was this helpful?