Next.js App Router: Layouts, Loading and Error UI (2026)
The Next.js App Router uses a file-system convention where special filenames define the UI for layouts, loading states, error boundaries, 404 pages and more. Each route segment gets its own folder under app/, and co-locating these special files gives you fine-grained control over every state of every route — without wiring up routing logic manually.
Table of Contents
File Conventions
Inside any folder under app/, these filenames have special meaning:
app/
layout.tsx ← Persistent wrapper — does NOT remount on navigation
page.tsx ← The route's UI — makes the folder a public URL
loading.tsx ← Suspense fallback shown while page.tsx loads
error.tsx ← Error boundary for this segment (must be 'use client')
not-found.tsx ← Rendered when notFound() is thrown in this segment
template.tsx ← Like layout but DOES remount on navigation
default.tsx ← Fallback for parallel routes
page.tsx makes a folder a publicly accessible URL. A layout.tsx wraps the page but doesn't create a URL itself. You can have a layout without a page (for shared UI) or a page without a layout (inherits parent layouts).
Layouts and Nested Layouts
// app/layout.tsx — Root layout (required)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}
// app/dashboard/layout.tsx — Nested layout for /dashboard/*
export default function DashboardLayout({ children }) {
return (
<div className="dashboard-shell">
<DashboardSidebar />
<main className="dashboard-content">
{children}
</main>
</div>
)
}
// app/dashboard/page.tsx — /dashboard
export default function DashboardPage() {
return <h1>Dashboard Overview</h1>
}
// app/dashboard/analytics/page.tsx — /dashboard/analytics
// Both RootLayout and DashboardLayout wrap this page
Layouts don't re-render when navigating between child routes — only the page content changes. This keeps shared UI (sidebars, headers) stable and avoids unnecessary re-renders.
Loading UI
loading.tsx automatically wraps the page in a React Suspense boundary:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="loading-shell">
<div className="skeleton skeleton-title" />
<div className="skeleton skeleton-card" />
<div className="skeleton skeleton-card" />
</div>
)
}
// The loading UI appears instantly on navigation
// while app/dashboard/page.tsx fetches data
Loading UI at different levels creates layered skeleton screens:
app/
loading.tsx ← Root loading (full page skeleton)
dashboard/
loading.tsx ← Dashboard loading (sidebar visible, content skeletal)
analytics/
loading.tsx ← Analytics loading (chart skeleton)
Error UI
// app/dashboard/error.tsx — catches errors in /dashboard segment
'use client' // Error boundaries must be Client Components
import { useEffect } from 'react'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
// Log to error tracking
console.error(error)
}, [error])
return (
<div className="error-container">
<h2>Something went wrong loading the dashboard</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
// app/global-error.tsx — catches errors in root layout
// Must include <html> and <body> since root layout is broken
'use client'
export default function GlobalError({ error, reset }) {
return (
<html><body>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</body></html>
)
}
Not Found
// app/not-found.tsx — global 404
export default function NotFound() {
return (
<div>
<h2>404 — Page Not Found</h2>
<a href="/">Return Home</a>
</div>
)
}
// Trigger not-found programmatically in a Server Component
import { notFound } from 'next/navigation'
async function ProductPage({ params }) {
const product = await fetchProduct(params.id)
if (!product) notFound() // Renders nearest not-found.tsx
return <ProductDetail product={product} />
}
Route Groups
Folders wrapped in (parentheses) are route groups — they organize routes without affecting the URL:
app/
(marketing)/ ← Group — not in URL
layout.tsx ← Marketing layout (no sidebar)
page.tsx ← /
about/page.tsx ← /about
(dashboard)/ ← Group — not in URL
layout.tsx ← Dashboard layout (with sidebar)
dashboard/page.tsx ← /dashboard
analytics/page.tsx ← /analytics
// Use case: different layouts for authenticated vs public routes
app/
(auth)/
layout.tsx ← Auth layout (centered card)
login/page.tsx ← /login
register/page.tsx ← /register
(app)/
layout.tsx ← App layout (requires auth check)
dashboard/page.tsx
Dynamic Routes
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
return <article>{post.content}</article>
}
// Generate static params at build time
export async function generateStaticParams() {
const posts = await fetchAllPosts()
return posts.map(post => ({ slug: post.slug }))
}
// Catch-all routes: app/docs/[...slug]/page.tsx
// /docs/getting-started → params.slug = ['getting-started']
// /docs/api/auth → params.slug = ['api', 'auth']
// Optional catch-all: app/[[...slug]]/page.tsx
// Also matches / with params.slug = undefined
Parallel Routes
Render multiple pages simultaneously in the same layout using named slots (@folder):
app/
dashboard/
layout.tsx
page.tsx
@analytics/
page.tsx ← Rendered in @analytics slot
@team/
page.tsx ← Rendered in @team slot
// dashboard/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
<div>{children}</div>
<div className="side-panels">
{analytics}
{team}
</div>
</div>
)
}
Intercepting Routes
Show a modal when navigating from within the app but the full page when navigating directly:
app/
feed/page.tsx ← /feed
photo/[id]/page.tsx ← /photo/123 — full page
feed/
(.)photo/[id]/page.tsx ← Intercepts /photo/123 from /feed — shows modal
// (.) = same level, (..) = one level up, (...) = from root
Server Actions
// app/posts/new/page.tsx
import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'
async function createPost(formData: FormData) {
'use server' // This function runs on the server
const title = formData.get('title') as string
const body = formData.get('body') as string
await db.post.create({ data: { title, body } })
revalidatePath('/posts')
redirect('/posts')
}
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="body" required />
<button type="submit">Create Post</button>
</form>
)
}