App Router File-System Routing: The Complete Guide
The File System as a Route Declaration
One of App Router's foundational design principles is that directory structure equals URL structure. The folders you create under app/ directly determine the shape of your URLs. This is not a new idea โ Pages Router did the same โ but App Router advances the concept significantly: not only does the file system determine routing, it also determines the functional role of each file through a set of reserved filenames.
A typical Next.js 15 project's app/ directory might look like this:
app/
โโโ layout.tsx # Root layout (required)
โโโ page.tsx # Home page /
โโโ globals.css
โโโ blog/
โ โโโ layout.tsx # Shared layout for all blog routes
โ โโโ page.tsx # /blog
โ โโโ [slug]/
โ โโโ page.tsx # /blog/:slug
โ โโโ loading.tsx # Loading state for /blog/:slug
โโโ dashboard/
โ โโโ layout.tsx
โ โโโ page.tsx # /dashboard
โ โโโ settings/
โ โโโ page.tsx # /dashboard/settings
โโโ api/
โโโ posts/
โโโ route.ts # API endpoint: GET/POST /api/posts
This directory tree maps directly to the URL tree. Understanding this mapping is the first step to mastering App Router.
Convention Files: Every Filename Has a Meaning
App Router defines a set of reserved filenames, each playing a specific role in the routing system. Non-reserved files in the same directory โ like components/, utils.ts, PostContent.tsx โ do not become routes. This is a significant improvement over Pages Router and enables colocation, placing code alongside the routes that use it.
page.tsx โ The Route Entry Point
page.tsx is the file that makes a directory an accessible route. Without page.tsx, a directory is just a folder โ it corresponds to no URL.
// app/blog/page.tsx
// Accessible at: /blog
import { getPosts } from '@/lib/data';
export default async function BlogPage() {
const posts = await getPosts();
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
</main>
);
}
The default export from page.tsx receives two props: params (dynamic route parameters) and searchParams (URL query parameters). In Next.js 15, both props are Promises and must be awaited before use. This is a breaking change from Next.js 14 and reflects the framework's move toward fully async rendering:
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
searchParams: Promise<{ tab?: string }>;
}
export default async function PostPage({ params, searchParams }: Props) {
const { slug } = await params;
const { tab = 'content' } = await searchParams;
// ...
}
The reason params became a Promise in Next.js 15 is architectural: it allows Next.js to defer resolving dynamic parameters until they are actually needed, enabling better streaming and partial prerendering support.
layout.tsx โ Persistent UI Across Routes
layout.tsx defines persistent UI that wraps child routes. Unlike pages, layouts do not re-render on route navigation โ they persist. This makes them ideal for navigation bars, sidebars, authentication contexts, and any UI that should survive page transitions.
The root layout (app/layout.tsx) is a required file that must contain <html> and <body> tags. It is the outermost shell of the entire application:
// app/layout.tsx โ Root layout, the application shell
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 | My Site', default: 'My Site' },
description: 'A Next.js 15 application',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<header>
<nav>{/* Global navigation โ shared by all pages */}</nav>
</header>
<main>{children}</main>
<footer>{/* Global footer */}</footer>
</body>
</html>
);
}
Nested layouts form a layout tree. app/dashboard/layout.tsx wraps all /dashboard/* routes, while the root layout wraps everything:
// app/dashboard/layout.tsx
// Wraps /dashboard, /dashboard/settings, /dashboard/users, etc.
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="dashboard-container">
<aside className="sidebar">
<DashboardNav />
</aside>
<section className="content">{children}</section>
</div>
);
}
Why don't layouts re-render on navigation? Because Next.js knows which layout segments are shared between the current and next route and skips re-rendering them. When you navigate from /dashboard/settings to /dashboard/users, the root layout and dashboard layout are identical in both routes, so they are preserved. Only the page component โ the leaf โ is replaced. This is fundamentally more efficient than the Pages Router's _app.tsx, which re-ran on every navigation.
loading.tsx โ Automatic Suspense Boundaries
loading.tsx is a special file that Next.js automatically wraps in a <Suspense> boundary. When the route's page.tsx is performing asynchronous work on the server โ database queries, remote API calls โ the content of loading.tsx is immediately shown to the user without waiting for data.
This leverages React's streaming rendering capability: the server sends loading.tsx's HTML first, establishing an instant response. When page.tsx completes rendering, the actual content is streamed in to replace the loading state.
// app/blog/[slug]/loading.tsx
// Shown instantly while the blog post data is being fetched
export default function PostLoading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/4 mb-8" />
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
</div>
);
}
Before loading.tsx existed, you had to manually wrap components in <Suspense> and provide fallback props. App Router makes this automatic by convention: add a loading.tsx file and the streaming behavior is configured for you. The file scopes to its directory โ app/blog/loading.tsx covers all blog routes, while app/blog/[slug]/loading.tsx covers only individual post routes.
error.tsx โ Error Boundaries
error.tsx is a React error boundary that catches runtime errors thrown within the route and its children. It must be a Client Component โ React error boundaries require stateful behavior that only exists on the client. It receives two props: error (the caught error) and reset (a function to retry rendering):
// app/blog/[slug]/error.tsx
'use client';
import { useEffect } from 'react';
interface Props {
error: Error & { digest?: string };
reset: () => void;
}
export default function PostError({ error, reset }: Props) {
useEffect(() => {
// Report error to monitoring service
console.error('Post page error:', error);
}, [error]);
return (
<div className="error-container">
<h2>Something went wrong</h2>
<p>We had trouble loading this post. Please try again.</p>
<button onClick={reset}>Try Again</button>
</div>
);
}
The reset function attempts to re-render the failed route segment. error.digest is a hash identifier for server-side errors โ it lets you find the corresponding error in server logs without exposing internal error details to the client. This is an important security property: the error.message sent to the browser for server errors is a generic message, not the actual exception.
not-found.tsx โ 404 Handling
not-found.tsx is triggered in two scenarios: when a route simply does not exist (Next.js handles this automatically), or when code explicitly calls the notFound() function from next/navigation:
// app/blog/[slug]/not-found.tsx
import Link from 'next/link';
export default function PostNotFound() {
return (
<div>
<h2>Post Not Found</h2>
<p>The post you're looking for may have been removed or the link is incorrect.</p>
<Link href="/blog">Back to Blog</Link>
</div>
);
}
// In app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound(); // Triggers the nearest not-found.tsx
return <Article post={post} />;
}
The notFound() function throws a special internal error that Next.js intercepts before it reaches error.tsx. This is why 404s and runtime errors can have separate UI โ they are handled by different convention files.
route.ts โ API Endpoints
route.ts turns a directory into an HTTP endpoint rather than a page. The same directory cannot have both page.tsx and route.ts โ they represent mutually exclusive interpretations of the same URL path.
route.ts exports functions named after HTTP methods:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db';
// GET /api/posts?page=1&limit=10
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const posts = await db.posts.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
});
return NextResponse.json({ posts, page, limit });
}
// POST /api/posts
export async function POST(request: NextRequest) {
const body = await request.json();
// Production code should authenticate and validate input here
const post = await db.posts.create({ data: body });
return NextResponse.json(post, { status: 201 });
}
Route handlers can also export PUT, PATCH, DELETE, HEAD, and OPTIONS functions. They receive the NextRequest object (an extension of the Web API Request) and must return a Response or NextResponse.
Colocation: Non-Route Files in app/
One of App Router's most practical improvements over Pages Router is that only reserved filenames become routes. Every other file in app/ is invisible to the router, which means you can place components, utilities, styles, and tests directly next to the routes that use them:
app/
โโโ blog/
โโโ [slug]/
โโโ page.tsx # Route (convention file)
โโโ loading.tsx # Loading state (convention file)
โโโ error.tsx # Error boundary (convention file)
โโโ PostContent.tsx # Component (NOT a route)
โโโ AuthorCard.tsx # Component (NOT a route)
โโโ PostContent.test.tsx # Test (NOT a route)
โโโ utils.ts # Utilities (NOT a route)
PostContent.tsx is never mapped to /blog/[slug]/PostContent. Only page.tsx creates an accessible URL. This is a significant quality-of-life improvement: in Pages Router, any file under pages/ became a route, which forced teams to keep components in a separate components/ directory at the root level, away from the routes that used them.
Colocation reduces the cognitive distance between code and its use context. When you're looking at app/blog/[slug]/page.tsx and need to understand PostContent, it's right there in the same folder.
Generating Metadata
Next.js 15 provides two ways to generate <head> metadata for pages: a static metadata export, or a dynamic generateMetadata async function.
The static form works for pages whose metadata doesn't depend on dynamic data:
// app/blog/page.tsx โ static metadata
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Blog',
description: 'All posts from our blog',
};
The dynamic form runs on the server and receives the same props as the page component:
// app/blog/[slug]/page.tsx โ dynamic metadata
import type { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
// generateMetadata shares params with the page component.
// Next.js deduplicates data requests automatically โ the
// database is queried only once even though both functions call getPost().
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // Deduplicated with the page's request
if (!post) {
return { title: 'Post Not Found' };
}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage, width: 1200, height: 630 }],
type: 'article',
publishedTime: post.publishedAt.toISOString(),
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug); // Same request, served from React cache
if (!post) notFound();
return <Article post={post} />;
}
The deduplication is handled by React's cache() function. When you wrap your data access functions with cache(), multiple calls within the same render pass return the same promise. This is why generateMetadata and the page component can both call getPost(slug) without triggering two database queries.
The Difference Between page.tsx and route.ts
Both files occupy a URL path, but they serve fundamentally different purposes:
page.tsxreturns a React component tree. Next.js renders it to HTML (on initial load) or an RSC payload (on client navigation) and sends it to the browser.route.tsreturns a raw HTTP response. It is a pure API endpoint with no rendering involved.
The constraint that they cannot coexist in the same directory is not arbitrary โ it's a clarification of intent. A URL should either be a page people navigate to, or an endpoint applications talk to. Mixing both at the same path would be confusing and, in practice, rarely necessary.
In terms of internal processing, Next.js routes both through the same file-system routing engine. The difference is entirely in what the handler returns and how Next.js serves that response.
Summary
App Router's file-system routing makes route structure self-documenting: the directory tree shows the URL structure, and reserved filenames communicate the functional role of each file. Six convention files โ page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx, and route.ts โ form the complete vocabulary of the routing system. Colocation enables placing components and utilities alongside the routes that use them without accidentally exposing them as URLs. Dynamic metadata generation through generateMetadata integrates cleanly with server-side data fetching and request deduplication. Mastering these conventions is the prerequisite for every advanced App Router capability covered in subsequent chapters.