Next.js App Router
The App Router is Next.js routing system introduced in version 13. It uses the app/ directory and is built on top of React Server Components. Unlike the older Pages Router, the App Router provides nested layouts, co-located data fetching, streaming with Suspense, and advanced routing patterns like parallel and intercepting routes.
Every folder inside app/ becomes a URL segment. Special files within those folders define the behavior of the route โ page.tsx for the UI, layout.tsx for shared wrapping UI, loading.tsx for loading states, and error.tsx for error boundaries.
info
The App Router defines several special file conventions. Each has a specific role and placement rules within the route tree.
page.tsx โ The Route UI
Defines the unique UI for a route. Without a page.tsx, the folder is a layout-only segment. A page receives params and searchParams as props.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | export default async function BlogPost({ |
| 3 | params, |
| 4 | searchParams, |
| 5 | }: { |
| 6 | params: Promise<{ slug: string }>; |
| 7 | searchParams: Promise<{ [key: string]: string | string[] | undefined }>; |
| 8 | }) { |
| 9 | const { slug } = await params; |
| 10 | const { version } = await searchParams; |
| 11 | |
| 12 | return ( |
| 13 | <article> |
| 14 | <h1>Post: {slug}</h1> |
| 15 | {version && <p>Viewing version: {version}</p>} |
| 16 | </article> |
| 17 | ); |
| 18 | } |
layout.tsx โ Shared Layout
Wraps child pages and layouts. Layouts persist across navigations โ they do not remount when the user navigates between child routes. This makes them ideal for sidebars, navigation bars, and other persistent UI.
| 1 | // app/dashboard/layout.tsx |
| 2 | import { Sidebar } from "@/components/sidebar"; |
| 3 | import { TopBar } from "@/components/topbar"; |
| 4 | |
| 5 | export default function DashboardLayout({ |
| 6 | children, |
| 7 | }: { |
| 8 | children: React.ReactNode; |
| 9 | }) { |
| 10 | return ( |
| 11 | <div className="flex h-screen"> |
| 12 | <Sidebar /> |
| 13 | <div className="flex-1 flex flex-col"> |
| 14 | <TopBar /> |
| 15 | <main className="flex-1 overflow-auto p-6"> |
| 16 | {children} |
| 17 | </main> |
| 18 | </div> |
| 19 | </div> |
| 20 | ); |
| 21 | } |
loading.tsx โ Instant Loading UI
Automatically wraps the nearest page in a React Suspense boundary. The loading UI is streamed immediately while the page content resolves. This eliminates the need for manual loading state management in most cases.
| 1 | // app/dashboard/loading.tsx |
| 2 | export default function DashboardLoading() { |
| 3 | return ( |
| 4 | <div className="space-y-4 animate-pulse"> |
| 5 | <div className="h-10 bg-gray-800 rounded w-1/4" /> |
| 6 | <div className="grid grid-cols-3 gap-4"> |
| 7 | {[1, 2, 3].map((i) => ( |
| 8 | <div key={i} className="h-32 bg-gray-800 rounded" /> |
| 9 | ))} |
| 10 | </div> |
| 11 | </div> |
| 12 | ); |
| 13 | } |
error.tsx โ Error Boundary
Catches runtime errors in the route segment. Must be a Client Component with 'use client'. Receives an error prop and a reset function to retry rendering.
| 1 | // app/dashboard/error.tsx |
| 2 | "use client"; |
| 3 | |
| 4 | import { useEffect } from "react"; |
| 5 | |
| 6 | export default function DashboardError({ |
| 7 | error, |
| 8 | reset, |
| 9 | }: { |
| 10 | error: Error & { digest?: string }; |
| 11 | reset: () => void; |
| 12 | }) { |
| 13 | useEffect(() => { |
| 14 | // Log to error reporting service |
| 15 | console.error("Dashboard error:", error); |
| 16 | }, [error]); |
| 17 | |
| 18 | return ( |
| 19 | <div className="rounded-lg border border-red-500/20 p-6"> |
| 20 | <h2 className="text-red-400 font-mono text-sm mb-2"> |
| 21 | Something went wrong |
| 22 | </h2> |
| 23 | <p className="text-gray-400 text-xs mb-4">{error.message}</p> |
| 24 | <button |
| 25 | onClick={() => reset()} |
| 26 | className="px-4 py-2 bg-red-500/10 text-red-400 rounded text-xs font-mono" |
| 27 | > |
| 28 | Try again |
| 29 | </button> |
| 30 | </div> |
| 31 | ); |
| 32 | } |
not-found.tsx โ 404 UI
Renders when notFound() is called or when no matching route is found. You can have different not-found pages at different levels of the route tree.
| 1 | // app/not-found.tsx โ Global 404 |
| 2 | import Link from "next/link"; |
| 3 | |
| 4 | export default function NotFound() { |
| 5 | return ( |
| 6 | <div className="flex flex-col items-center justify-center min-h-[60vh]"> |
| 7 | <span className="text-6xl font-mono text-gray-700 mb-4">404</span> |
| 8 | <h2 className="text-xl font-mono text-gray-300 mb-2"> |
| 9 | Page Not Found |
| 10 | </h2> |
| 11 | <p className="text-sm text-gray-500 mb-6"> |
| 12 | The page you are looking for does not exist or has been moved. |
| 13 | </p> |
| 14 | <Link |
| 15 | href="/" |
| 16 | className="px-4 py-2 bg-[#00FF41]/10 text-[#00FF41] rounded text-sm font-mono" |
| 17 | > |
| 18 | Go Home |
| 19 | </Link> |
| 20 | </div> |
| 21 | ); |
| 22 | } |
Dynamic routes use bracket syntax in folder names to capture URL segments. The captured values are available as params in the page and layout components.
Single Dynamic Segment
| 1 | // app/blog/[slug]/page.tsx |
| 2 | // Matches: /blog/hello-world, /blog/my-post |
| 3 | |
| 4 | export default async function BlogPost({ |
| 5 | params, |
| 6 | }: { |
| 7 | params: Promise<{ slug: string }>; |
| 8 | }) { |
| 9 | const { slug } = await params; |
| 10 | |
| 11 | return <h1>Blog post: {slug}</h1>; |
| 12 | } |
Catch-All Routes
| 1 | // app/docs/[...slug]/page.tsx |
| 2 | // Matches: /docs/a, /docs/a/b, /docs/a/b/c |
| 3 | |
| 4 | export default async function DocsPage({ |
| 5 | params, |
| 6 | }: { |
| 7 | params: Promise<{ slug: string[] }>; |
| 8 | }) { |
| 9 | const { slug } = await params; |
| 10 | // slug = ["a", "b", "c"] for /docs/a/b/c |
| 11 | |
| 12 | return ( |
| 13 | <div> |
| 14 | <p>Path segments: {slug.join(" > ")}</p> |
| 15 | </div> |
| 16 | ); |
| 17 | } |
Optional Catch-All Routes
| 1 | // app/shop/[[...slug]]/page.tsx |
| 2 | // Matches: /shop, /shop/a, /shop/a/b |
| 3 | |
| 4 | export default async function ShopPage({ |
| 5 | params, |
| 6 | }: { |
| 7 | params: Promise<{ slug?: string[] }>; |
| 8 | }) { |
| 9 | const { slug } = await params; |
| 10 | |
| 11 | // slug is undefined for /shop |
| 12 | // slug = ["electronics", "phones"] for /shop/electronics/phones |
| 13 | |
| 14 | if (!slug) { |
| 15 | return <h1>All Products</h1>; |
| 16 | } |
| 17 | |
| 18 | return <h1>Category: {slug.join(" > ")}</h1>; |
| 19 | } |
generateStaticParams
Pre-render dynamic routes at build time. Return an array of param objects and Next.js generates static HTML for each.
| 1 | // app/blog/[slug]/page.tsx |
| 2 | export async function generateStaticParams() { |
| 3 | const posts = await fetch("https://api.example.com/posts").then( |
| 4 | (res) => res.json() |
| 5 | ); |
| 6 | |
| 7 | return posts.map((post: { slug: string }) => ({ |
| 8 | slug: post.slug, |
| 9 | })); |
| 10 | } |
| 11 | |
| 12 | // Optional: generate metadata per page |
| 13 | export async function generateMetadata({ |
| 14 | params, |
| 15 | }: { |
| 16 | params: Promise<{ slug: string }>; |
| 17 | }) { |
| 18 | const { slug } = await params; |
| 19 | const post = await getPost(slug); |
| 20 | return { title: post.title, description: post.excerpt }; |
| 21 | } |
info
Route groups organize routes into logical groups without affecting the URL. Wrap the folder name in parentheses. This is useful for applying different layouts to different sections of your app.
| 1 | app/ |
| 2 | โโโ (marketing)/ |
| 3 | โ โโโ layout.tsx # Marketing layout (large nav, hero) |
| 4 | โ โโโ pricing/ |
| 5 | โ โ โโโ page.tsx # /pricing |
| 6 | โ โโโ features/ |
| 7 | โ โโโ page.tsx # /features |
| 8 | โ |
| 9 | โโโ (dashboard)/ |
| 10 | โ โโโ layout.tsx # Dashboard layout (sidebar, topbar) |
| 11 | โ โโโ dashboard/ |
| 12 | โ โ โโโ page.tsx # /dashboard |
| 13 | โ โโโ settings/ |
| 14 | โ โโโ page.tsx # /settings |
| 15 | โ |
| 16 | โโโ (auth)/ |
| 17 | โโโ layout.tsx # Auth layout (centered card) |
| 18 | โโโ login/ |
| 19 | โ โโโ page.tsx # /login |
| 20 | โโโ register/ |
| 21 | โโโ page.tsx # /register |
| 1 | // app/(marketing)/layout.tsx |
| 2 | export default function MarketingLayout({ |
| 3 | children, |
| 4 | }: { |
| 5 | children: React.ReactNode; |
| 6 | }) { |
| 7 | return ( |
| 8 | <div> |
| 9 | <header className="bg-white shadow"> |
| 10 | <nav className="max-w-7xl mx-auto px-4 py-4"> |
| 11 | <a href="/" className="text-lg font-bold">MyApp</a> |
| 12 | <div className="flex gap-6"> |
| 13 | <a href="/pricing">Pricing</a> |
| 14 | <a href="/features">Features</a> |
| 15 | </div> |
| 16 | </nav> |
| 17 | </header> |
| 18 | <main>{children}</main> |
| 19 | <footer className="bg-gray-50 py-8"> |
| 20 | <p>© 2026 MyApp</p> |
| 21 | </footer> |
| 22 | </div> |
| 23 | ); |
| 24 | } |
info
Parallel routes allow you to render multiple pages in the same layout simultaneously. Define named slots in the layout and create matching folders with@ prefix.
| 1 | app/ |
| 2 | โโโ dashboard/ |
| 3 | โ โโโ layout.tsx # Defines slots: @analytics, @team |
| 4 | โ โโโ page.tsx # Main content |
| 5 | โ โโโ @analytics/ |
| 6 | โ โ โโโ page.tsx # Analytics panel |
| 7 | โ โโโ @team/ |
| 8 | โ โ โโโ page.tsx # Team panel |
| 9 | โ โโโ @analytics/ |
| 10 | โ โโโ error.tsx # Error boundary for analytics slot |
| 1 | // app/dashboard/layout.tsx |
| 2 | export default function DashboardLayout({ |
| 3 | children, |
| 4 | analytics, |
| 5 | team, |
| 6 | }: { |
| 7 | children: React.ReactNode; |
| 8 | analytics: React.ReactNode; |
| 9 | team: React.ReactNode; |
| 10 | }) { |
| 11 | return ( |
| 12 | <div className="grid grid-cols-3 gap-4 p-4"> |
| 13 | <div className="col-span-2">{children}</div> |
| 14 | <div className="space-y-4"> |
| 15 | <div className="rounded-lg border p-4">{analytics}</div> |
| 16 | <div className="rounded-lg border p-4">{team}</div> |
| 17 | </div> |
| 18 | </div> |
| 19 | ); |
| 20 | } |
You can add a default.tsxfile in each slot to provide a fallback UI when the slot's URL does not match.
| 1 | // app/dashboard/@analytics/default.tsx |
| 2 | // Shown when /dashboard is visited (analytics slot has no matching URL) |
| 3 | export default function AnalyticsDefault() { |
| 4 | return ( |
| 5 | <div className="text-gray-500 text-sm p-4"> |
| 6 | Select a time range to view analytics. |
| 7 | </div> |
| 8 | ); |
| 9 | } |
info
Intercepting routes let you render a route as if the user navigated to it, while the current URL stays the same. Use the (...) or (..) syntax in the folder name.
| 1 | app/ |
| 2 | โโโ feed/ |
| 3 | โ โโโ page.tsx # /feed โ grid of photos |
| 4 | โ โโโ [id]/ |
| 5 | โ โโโ page.tsx # /feed/123 โ full page photo |
| 6 | โ |
| 7 | โโโ photo/ |
| 8 | โ โโโ [id]/ |
| 9 | โ โโโ page.tsx # /photo/123 โ full page photo (direct URL) |
| 10 | โ |
| 11 | โโโ @modal/ |
| 12 | โโโ (..)photo/ |
| 13 | โโโ [id]/ |
| 14 | โโโ page.tsx # Intercepts /photo/123 as modal |
| 1 | // app/@modal/(..)photo/[id]/page.tsx |
| 2 | // Intercepts /photo/123 when navigating from /feed |
| 3 | // But renders as full page when navigating directly to /photo/123 |
| 4 | |
| 5 | import { Modal } from "@/components/modal"; |
| 6 | import { PhotoDetail } from "@/components/photo-detail"; |
| 7 | |
| 8 | export default async function PhotoModal({ |
| 9 | params, |
| 10 | }: { |
| 11 | params: Promise<{ id: string }>; |
| 12 | }) { |
| 13 | const { id } = await params; |
| 14 | |
| 15 | return ( |
| 16 | <Modal> |
| 17 | <PhotoDetail id={id} /> |
| 18 | </Modal> |
| 19 | ); |
| 20 | } |
pro tip
template.tsx
Similar to layout but remounts on every navigation. Use it when you need re-mounting behavior like triggering animation entrance effects on route changes.
| 1 | // app/dashboard/template.tsx |
| 2 | export default function DashboardTemplate({ |
| 3 | children, |
| 4 | }: { |
| 5 | children: React.ReactNode; |
| 6 | }) { |
| 7 | // This component remounts on every navigation |
| 8 | // Layout does NOT remount โ this is the key difference |
| 9 | return ( |
| 10 | <div className="animate-fade-in"> |
| 11 | {children} |
| 12 | </div> |
| 13 | ); |
| 14 | } |
default.tsx
Provides a fallback UI for parallel route slots when the URL does not match any of the slot's sub-routes. Without it, an unmatched parallel route slot renders nothing.
| 1 | // app/dashboard/@analytics/default.tsx |
| 2 | export default function AnalyticsFallback() { |
| 3 | return ( |
| 4 | <div className="rounded-lg border p-4"> |
| 5 | <p className="text-sm text-gray-500"> |
| 6 | No analytics data for this view. |
| 7 | </p> |
| 8 | </div> |
| 9 | ); |
| 10 | } |
Use usePathname to highlight the active link in your navigation.
| 1 | "use client"; |
| 2 | |
| 3 | import Link from "next/link"; |
| 4 | import { usePathname } from "next/navigation"; |
| 5 | |
| 6 | export function NavLink({ href, children }: { |
| 7 | href: string; |
| 8 | children: React.ReactNode; |
| 9 | }) { |
| 10 | const pathname = usePathname(); |
| 11 | const isActive = pathname === href || pathname.startsWith(href + "/"); |
| 12 | |
| 13 | return ( |
| 14 | <Link |
| 15 | href={href} |
| 16 | className={ |
| 17 | isActive |
| 18 | ? "text-[#00FF41] font-semibold" |
| 19 | : "text-gray-500 hover:text-gray-300" |
| 20 | } |
| 21 | > |
| 22 | {children} |
| 23 | </Link> |
| 24 | ); |
| 25 | } |
| Pattern | Example URL | Params |
|---|---|---|
| [slug] | /blog/hello-world | { slug: "hello-world" } |
| [...slug] | /docs/a/b/c | { slug: ["a", "b", "c"] } |
| [[...slug]] | /shop (or /shop/a/b) | { slug: undefined } or { slug: ["a", "b"] } |
| (group) | No URL change | N/A |
| @slot | No URL change | Parallel route slot |
| (...slug) | No URL change (intercept) | Catch segments for intercepting |
best practice