App Router File System Routing: A Comprehensive Guide to Pages, Layouts, and Dynamic Routing
File system routing is like how books are categorized in a library—filenames determine the URL path, and the directory structure serves as your routing table, without the need for any manual configuration.
1. What You'll Learn
- The core principle of file system routing: The filename is the URL
- The Six File Conventions: page, layout, loading, error, not-found, route
- Dynamic Routing
[id]Single-Parameter Mode - Catch-all Routes
[...slug]and Optional Catch-all[[...catchAll]] - Defining and Using Routes with the Route Handlers API
2. A True Story of a Technical Manager
(1) Pain Point: Manual routing configuration is chaotic
Bob is the technical lead at an e-commerce company, and his team maintains a 50-page React SPA. Every time a new page is added, developers have to manually configure three files:
"Our routing configuration table
routes.jshas 300 lines. Last week, Xiao Ming added a new product detail page but forgot to registerpath: '/products/:id'in the routing table, resulting in a 404 error that went unnoticed for a full two hours after the page went live."
Bob's team encountered the following routing management issues:
| Issue | Impact | Frequency |
|---|---|---|
| Missing routing configuration | 404 after deployment | 1–2 times per month |
| Manual Handling of Nested Routes | Complex code, confusing route hierarchy | Each new page |
| Scattered 404/500 Error Pages | Inconsistent, poor user experience | Multiple pages |
| Separate Maintenance of API Routes | One set of routes for the front end and one for the back end | Each API |
(2) Solutions for Next.js File System Routing
The filename is the URL, and the directory structure is the routing table—there is no longer a need for
react-router-domrouting configuration.
src/app/
├── page.tsx # → /
├── about/
│ └── page.tsx # → /about
├── products/
│ ├── page.tsx # → /products
│ └── [id]/
│ ├── page.tsx # → /products/1, /products/2
│ └── reviews/
│ └── page.tsx # → /products/1/reviews
└── api/
└── products/
└── route.ts # → /api/products (GET/POST)
(3) Revenue
| Dimension | Before (React Router) | After (File System Routing) |
|---|---|---|
| Steps to Add a Page | 3 steps (create a component + configure routing + import) | 1 step (create a file) |
| Routing Configuration Maintenance | 300-line routing table | Zero lines, automatically determined by the directory |
| 404 Errors Upon Launch | 1–2 times per month | 0 times |
| Unified Management of 404/500 Errors | Manual import | File conventions take effect automatically |
3. Principles of File System Routing
(1) The filename is the URL
graph TB
subgraph "src/app/ Directory Structure"
A[page.tsx] --> B[/]
C[about/page.tsx] --> D[/about]
E[products/page.tsx] --> F[/products]
G[products/promo/page.tsx] --> H[/products/promo]
I[products/id/page.tsx] --> J[/products/:id]
K[dashboard/settings/page.tsx] --> L[/dashboard/settings]
end
style A fill:#d4edda
style C fill:#d4edda
style E fill:#d4edda
| File Name | Corresponding URL | Description |
|---|---|---|
app/page.tsx |
/ |
Home |
app/about/page.tsx |
/about |
Static Page |
app/blog/page.tsx |
/blog |
Blog List |
app/blog/[id]/page.tsx |
/blog/1 |
Dynamic Routing |
app/dashboard/settings/page.tsx |
/dashboard/settings |
Nested Multi-Level |
(2) Provisions in the Six Major Documents
| File Name | Purpose | Required? | Rendering Behavior |
|---|---|---|---|
page.tsx |
Page Component (UI Content) | ✅ | Server Component (default) |
layout.tsx |
Layout Container (Persistent State) | ✅ Root layout | Do not remount during navigation |
loading.tsx |
Load skeleton screen | Optional | Suspense fallback |
error.tsx |
Error Boundary UI | Optional | Capture Child Component Errors |
not-found.tsx |
404 Page | Optional | notFound() Trigger |
route.ts |
API Endpoint | Optional | Server-side Execution |
▶ Example: Creating Your First Page
// ============================================
// Create /about Page
// Document:src/app/about/page.tsx
// Just create a file,Automatic Route Registration
// ============================================
export default function AboutPage() {
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold">About TaskFlow</h1>
<p className="mt-4 text-gray-600 leading-relaxed">
TaskFlow is a collaborative project management platform that helps
teams plan, track, and deliver projects efficiently. Built with
Next.js 16 and React 19, it provides real-time updates, seamless
collaboration, and enterprise-grade security.
</p>
<div className="mt-8 grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-lg text-center">
<div className="text-2xl font-bold text-blue-600">10K+</div>
<div className="text-sm text-gray-500">Active Users</div>
</div>
<div className="p-4 bg-green-50 rounded-lg text-center">
<div className="text-2xl font-bold text-green-600">50K+</div>
<div className="text-sm text-gray-500">Projects</div>
</div>
<div className="p-4 bg-purple-50 rounded-lg text-center">
<div className="text-2xl font-bold text-purple-600">99.9%</div>
<div className="text-sm text-gray-500">Uptime</div>
</div>
</div>
</div>
);
}
Output:
Visit http://localhost:3000/about See:
About TaskFlow
TaskFlow is a collaborative project management platform...
10K+ Active Users | 50K+ Projects | 99.9% Uptime
4. Dynamic Routing
(1) Single-Parameter Dynamic Routing [id]
graph LR
A[app/products] --> B[page.tsx → /products]
A --> C[[id]]
C --> D[page.tsx → /products/1]
C --> E[/products/2]
style B fill:#d4edda
style D fill:#cce5ff
| File Mode | URL Example | params Value |
|---|---|---|
app/blog/[slug]/page.tsx |
/blog/hello-world |
{ slug: 'hello-world' } |
app/products/[id]/page.tsx |
/products/42 |
{ id: '42' } |
app/users/[userId]/settings/page.tsx |
/users/123/settings |
{ userId: '123' } |
▶ Example: Dynamic Product Details Page
// ============================================
// Product Details Page — Dynamic Routing [id]
// Document:src/app/products/[id]/page.tsx
// ============================================
// In Next.js 16, params is a Promise (asynchronous)
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await fetch(`https://fakestoreapi.com/products/${id}`);
return (
<div className="container mx-auto p-8">
<div className="flex gap-8">
<img
src={product.image}
alt={product.title}
className="w-80 h-80 object-contain"
/>
<div>
<h1 className="text-2xl font-bold">{product.title}</h1>
<p className="text-xl text-green-600 font-bold mt-2">
${product.price}
</p>
<p className="text-sm text-gray-500 mt-1">
Category: {product.category}
</p>
<p className="mt-4 text-gray-700">{product.description}</p>
</div>
</div>
</div>
);
}
Output:
Visit /products/1 See:
Fjallraven - Foldsack No. 1 Backpack
$109.95
Category: men's clothing
Your perfect pack for day trips and hikes...
(2) Catch-all Routes [...slug]
graph TB
A[app/docs] --> B[[...slug]]
B --> C[page.tsx]
C --> D[/docs/getting-started]
C --> E[/docs/guides/installation]
C --> F[/docs/api/authentication/overview]
style C fill:#cce5ff
| File Mode | URL | params.slug |
|---|---|---|
app/docs/[...slug]/page.tsx |
/docs |
404 (Level 1 is required) |
app/docs/[...slug]/page.tsx |
/docs/getting-started |
['getting-started'] |
app/docs/[...slug]/page.tsx |
/docs/guides/installation |
['guides', 'installation'] |
app/docs/[...slug]/page.tsx |
/docs/a/b/c |
['a', 'b', 'c'] |
▶ Example: Catch-all Documentation Page
// ============================================
// Catch-all Routing — Multi-level Document Pages
// Document:src/app/docs/[...slug]/page.tsx
// ============================================
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>;
}) {
const { slug } = await params;
return (
<div className="container mx-auto p-8">
<nav className="text-sm text-gray-500 mb-4">
Home / Docs / {slug.join(" / ")}
</nav>
<h1 className="text-3xl font-bold">Docs: {slug.join(" > ")}</h1>
<div className="mt-8 p-6 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="font-medium">You are viewing documentation for:</p>
<ul className="mt-2 list-disc list-inside">
{slug.map((segment, index) => (
<li key={index}>
Level {index + 1}: <code className="bg-gray-100 px-2 py-0.5 rounded">{segment}</code>
</li>
))}
</ul>
</div>
<p className="mt-4 text-gray-600">
Total depth: {slug.length} levels
</p>
</div>
);
}
Output:
Visit /docs/getting-started/installation See:
Home / Docs / getting-started / installation
Docs: getting-started > installation
You are viewing documentation for:
- Level 1: getting-started
- Level 2: installation
Total depth: 2 levels
(3) Optional Catch-all [[...catchAll]]
graph TB
A[app/categories] --> B[[[...catchAll]]]
B --> C[page.tsx]
C --> D[/categories]
C --> E[/categories/electronics]
C --> F[/categories/electronics/phones]
style C fill:#d4edda
style D fill:#d4edda
| File Mode | URL | params.catchAll |
|---|---|---|
app/categories/[[...catchAll]]/page.tsx |
/categories |
undefined |
app/categories/[[...catchAll]]/page.tsx |
/categories/electronics |
['electronics'] |
app/categories/[[...catchAll]]/page.tsx |
/categories/electronics/phones |
['electronics', 'phones'] |
▶ Example: Optional Catch-all Category Page
// ============================================
// Optional Catch-all — Category Browse Page
// Document:src/app/categories/[[...categories]]/page.tsx
// Both /categories and /categories/electronics take effect
// ============================================
export default async function CategoriesPage({
params,
}: {
params: Promise<{ categories?: string[] }>;
}) {
const { categories } = await params;
const allProducts = await fetch("https://fakestoreapi.com/products");
const categoriesList = [...new Set(allProducts.map(p => p.category))];
const filteredProducts = categories
? allProducts.filter(p => p.category === categories[0])
: allProducts;
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">
{categories ? `Category: ${categories[0]}` : "All Categories"}
</h1>
{!categories && (
<div className="flex gap-2 mb-8 flex-wrap">
{categoriesList.map(cat => (
<a
key={cat}
href={`/categories/${encodeURIComponent(cat)}`}
className="px-4 py-2 bg-gray-100 rounded-full hover:bg-blue-100"
>
{cat}
</a>
))}
</div>
)}
<div className="grid grid-cols-4 gap-6">
{filteredProducts.slice(0, 8).map(p => (
<div key={p.id} className="border rounded-lg p-4 hover:shadow-lg">
<img src={p.image} alt={p.title} className="h-40 mx-auto" />
<p className="mt-2 font-medium text-sm truncate">{p.title}</p>
<p className="text-green-600 font-bold">${p.price}</p>
</div>
))}
</div>
</div>
);
}
Output:
Visit /categories See:
All Category Buttons(electronics, jewelery, men's clothing, women's clothing)
Visit /categories/electronics See:
Category: electronics
Show only electronics(8Product Card)
5. loading.tsx and error.tsx
(1) loading.tsx — Load the skeleton screen
graph LR
A[User Navigation] --> B[loading.tsx<br/>Wireframe Display]
B --> C[page.tsx<br/>Data Ready]
C --> D[Full Page]
style B fill:#f8d7da
style C fill:#d4edda
▶ Example: Loading a skeleton screen
// ============================================
// Loading the skeleton screen — Displayed when page data is retrieved
// Document:src/app/products/loading.tsx
// ============================================
export default function ProductsLoading() {
return (
<div className="container mx-auto p-8">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
<div className="grid grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4">
<div className="h-40 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded mt-2 animate-pulse" />
<div className="h-4 w-16 bg-gray-200 rounded mt-2 animate-pulse" />
</div>
))}
</div>
</div>
);
}
Output:
When visiting /products, display until data loading is complete:
8 A gray placeholder card(Animation with Pulses)
After the data has finished loading,The placeholder content is automatically replaced with the actual content
Flicker-free、No white screen,Smooth Transition
(2) error.tsx — Error Boundary
▶ Example: Error Page
// ============================================
// Error Boundary — Catching Child Component Errors
// Document:src/app/products/error.tsx
// ============================================
'use client';
export default function ProductsError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="container mx-auto p-8 text-center">
<div className="max-w-md mx-auto">
<h2 className="text-2xl font-bold text-red-600 mb-4">
Something went wrong!
</h2>
<p className="text-gray-600 mb-6">
{error.message || "Failed to load products. Please try again."}
</p>
<button
onClick={reset}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
Try Again
</button>
</div>
</div>
);
}
Output:
When data retrieval from the product page fails,Show:
Something went wrong!
Failed to load products. Please try again.
[Try Again] button → Click to retry automatically
(3) not-found.tsx — 404 Page
▶ Example: Custom 404 Page
// ============================================
// 404 Page — Display when the route does not exist
// Document:src/app/not-found.tsx
// ============================================
import Link from "next/link";
export default function NotFound() {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-6xl font-bold text-gray-300">404</h1>
<h2 className="text-2xl font-bold mt-4">Page Not Found</h2>
<p className="text-gray-500 mt-2">
The page you are looking for does not exist.
</p>
<Link
href="/"
className="inline-block mt-6 px-6 py-2 bg-blue-600 text-white rounded-lg"
>
Go Home
</Link>
</div>
</div>
);
}
Output:
Accessing a non-existent URL (e.g. /xyz), the browser displays:
404
Page Not Found
The page you are looking for does not exist.
[Go Home] Link
6. Route Handlers (API Routes)
(1) Basic Usage of route.ts
graph LR
A[Client Request] --> B[route.ts]
B --> C[GET /api/products]
B --> D[POST /api/products]
B --> E[PUT /api/products/:id]
B --> F[DELETE /api/products/:id]
style B fill:#cce5ff
| HTTP Method | Exported Function | Purpose |
|---|---|---|
GET |
export async function GET() |
Read Data |
POST |
export async function POST() |
Create Data |
PUT |
export async function PUT() |
Update Data |
DELETE |
export async function DELETE() |
Delete Data |
PATCH |
export async function PATCH() |
Partial Update |
▶ Example: Complete CRUD API
// ============================================
// Route Handler — Products CRUD API
// Document:src/app/api/products/route.ts
// ============================================
// Simulated Database
const products = [
{ id: 1, name: "Wireless Mouse", price: 29.99 },
{ id: 2, name: "Mechanical Keyboard", price: 89.99 },
];
// GET /api/products — Get the Product List
export async function GET() {
return Response.json(products);
}
// POST /api/products — Create a New Product
export async function POST(request: Request) {
const body = await request.json();
if (!body.name || !body.price) {
return Response.json(
{ error: "Name and price are required" },
{ status: 400 }
);
}
const newProduct = {
id: products.length + 1,
name: body.name,
price: body.price,
};
products.push(newProduct);
return Response.json(newProduct, { status: 201 });
}
// ============================================
// For a single product, API
// Document:src/app/api/products/[id]/route.ts
// ============================================
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const product = products.find(p => p.id === Number(id));
if (!product) {
return Response.json(
{ error: "Product not found" },
{ status: 404 }
);
}
return Response.json(product);
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const index = products.findIndex(p => p.id === Number(id));
if (index === -1) {
return Response.json(
{ error: "Product not found" },
{ status: 404 }
);
}
products.splice(index, 1);
return Response.json({ message: "Product deleted" });
}
Output:
Test API:
GET http://localhost:3000/api/products
→ [{"id":1,"name":"Wireless Mouse","price":29.99},...]
POST http://localhost:3000/api/products
Body: {"name":"USB Hub","price":19.99}
→ {"id":3,"name":"USB Hub","price":19.99} (201)
GET http://localhost:3000/api/products/1
→ {"id":1,"name":"Wireless Mouse","price":29.99}
DELETE http://localhost:3000/api/products/1
→ {"message":"Product deleted"} (200)
7. Complete Example: E-commerce Product Browsing System
// ============================================
// Comprehensive Example:E-commerce Product Browsing System
// Pages Covered、Layout、Loading、Error、API Routing
// ============================================
// src/app/layout.tsx — Root Layout
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className="bg-gray-50">
<nav className="bg-white shadow-sm p-4">
<a href="/" className="text-xl font-bold text-blue-600">
ShopHub
</a>
</nav>
<main>{children}</main>
</body>
</html>
);
}
// src/app/page.tsx — Home
export default function HomePage() {
return (
<div className="container mx-auto p-8 text-center">
<h1 className="text-4xl font-bold">Welcome to ShopHub</h1>
<p className="mt-4 text-gray-600">
Browse our collection of amazing products.
</p>
<a
href="/products"
className="inline-block mt-6 px-8 py-3 bg-blue-600 text-white rounded-lg"
>
Browse Products
</a>
</div>
);
}
// src/app/products/page.tsx — Product List
export default async function ProductsPage() {
const products = await fetch(
"https://fakestoreapi.com/products"
);
return (
<div className="container mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">All Products</h1>
<div className="grid grid-cols-4 gap-6">
{products.map(p => (
<a
key={p.id}
href={`/products/${p.id}`}
className="border rounded-lg p-4 bg-white hover:shadow-lg"
>
<img
src={p.image}
alt={p.title}
className="h-40 mx-auto"
/>
<p className="mt-2 font-medium text-sm">{p.title}</p>
<p className="text-green-600 font-bold mt-1">${p.price}</p>
</a>
))}
</div>
</div>
);
}
// src/app/products/[id]/page.tsx — Product Details
export default async function ProductDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const product = await fetch(
`https://fakestoreapi.com/products/${id}`
);
if (!product) {
notFound();
}
return (
<div className="container mx-auto p-8">
<a
href="/products"
className="text-blue-600 hover:underline mb-4 inline-block"
>
← Back to products
</a>
<div className="flex gap-8 bg-white p-8 rounded-lg shadow">
<img
src={product.image}
alt={product.title}
className="w-96 h-96 object-contain"
/>
<div>
<h1 className="text-3xl font-bold">{product.title}</h1>
<p className="text-2xl text-green-600 font-bold mt-4">
${product.price}
</p>
<p className="mt-6 text-gray-700 leading-relaxed">
{product.description}
</p>
<button className="mt-6 px-8 py-3 bg-blue-600 text-white rounded-lg">
Add to Cart
</button>
</div>
</div>
</div>
);
}
// src/app/products/loading.tsx — Loading the skeleton screen
export default function ProductsLoading() {
return (
<div className="container mx-auto p-8">
<div className="h-8 w-48 bg-gray-200 rounded animate-pulse mb-6" />
<div className="grid grid-cols-4 gap-6">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="border rounded-lg p-4 bg-white">
<div className="h-40 bg-gray-200 rounded animate-pulse" />
<div className="h-4 bg-gray-200 rounded mt-2 animate-pulse" />
<div className="h-4 w-16 bg-gray-200 rounded mt-2 animate-pulse" />
</div>
))}
</div>
</div>
);
}
// src/app/products/error.tsx — Error Boundary
'use client';
export default function ProductsError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="container mx-auto p-8 text-center">
<h2 className="text-2xl font-bold text-red-600">
Failed to load products
</h2>
<button
onClick={reset}
className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg"
>
Try Again
</button>
</div>
);
}
Expected Output:
Home(/):
Welcome to ShopHub → [Browse Products]
Product List(/products):
Loading... → 8 A gray skeleton screen
Loading complete → Product Card Grid(Includes images、Title、Price)
Product Details(/products/1):
Product Images (left) + Title/Price/Description/Add to Cart (right)
← Back to products Link Back to List
Error Status:
Failed to load products
[Try Again] button
404 Status:
Automatically from the root not-found.tsx Processing
❓ FAQ
params Promises in Next.js 16?params, searchParams, and similar variables were changed to Promises to support asynchronous page generation. If you use them directly params.id, you’ll encounter a TypeScript error; you must await params to retrieve their values.loading.tsx and Suspense?loading.tsx is a page-level Suspense fallback. Next.js automatically wraps loading.tsx in <Suspense>. If you need a component-level loading state, you should manually use <Suspense fallback={...}>.error.tsx need use client?error.tsx must be a Client Component because it needs to handle interactions (such as the reset button). Server Components cannot contain event handlers or hooks, so the 'use client' directive is required.route.ts and page.tsx coexist?page.tsx and route.ts are mutually exclusive. A directory can only contain one type of route handler—either a page (page.tsx) or an API (route.ts).📖 Summary
- File system routing: The filename serves as the URL path, eliminating the need for manual configuration of routing tables
page.tsxdefines the page content;layout.tsxdefines the layout container- Dynamic routing:
[id]matches a single parameter,[...slug]matches a multi-level path [[...catchAll]]Allows zero arguments (optional);[...slug]requires at least one argumentloading.tsxAutomatically wrap withSuspenseto display a loading skeleton screenerror.tsxis a client component that provides an error boundary and a retry buttonnot-found.tsxHandles 404 pages; thenotFound()function is triggered manuallyroute.tsDefines API endpoints that support GET/POST/PUT/DELETE
📝 Exercises
-
Basic Exercise (⭐): Create a page named
app/blog/[slug]/page.tsxin the project so that when/blog/hello-worldis accessed, the console outputsslug: hello-world. -
Advanced Exercise (⭐⭐): Create
app/api/todos/route.tsto implement a simple TODO API (GET returns a list, POST creates a TODO), and verify it by accessing it in a browser. -
Challenge (⭐⭐⭐): Create two implementations,
app/docs/[...slug]/page.tsxandapp/docs/[[...slug]]/page.tsx, to test the access behavior of/docs, and use a table to compare the differences between the two.



