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.

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
Route vs Layout: A 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>
  )
}