React Next.js App Router: Layouts, Loading and Error UI

The Next.js App Router, introduced in Next.js 13 and stable from Next.js 14 onwards, replaces the Pages Router with a file-system convention built on React Server Components. Folders under app/ define routes; special files like layout.tsx, loading.tsx, error.tsx, and not-found.tsx compose the full page shell automatically. This guide covers the complete file convention, nested layouts, streaming with Suspense, Server vs Client Components, route groups, parallel routes, and server-side data fetching patterns.

File Conventions and Route Segments

The App Router maps the app/ folder structure directly to URL segments. Every folder becomes a route segment; a page.tsx inside makes the segment publicly accessible. Special filenames at any level of nesting have reserved roles that Next.js wires together automatically.

app/
├── layout.tsx          // Root layout — wraps every page
├── page.tsx            // Homepage: /
├── loading.tsx         // Root loading UI
├── error.tsx           // Root error boundary
├── not-found.tsx       // 404 page
│
├── blog/
│   ├── layout.tsx      // Blog layout (wraps all /blog/* pages)
│   ├── page.tsx        // /blog
│   ├── loading.tsx     // Loading for /blog/*
│   │
│   └── [slug]/
│       ├── page.tsx    // /blog/my-post
│       └── error.tsx   // Error boundary for individual posts
│
├── (marketing)/        // Route group — no URL segment
│   ├── about/
│   │   └── page.tsx    // /about
│   └── pricing/
│       └── page.tsx    // /pricing
│
├── dashboard/
│   ├── layout.tsx
│   ├── page.tsx        // /dashboard
│   ├── @analytics/     // Parallel route slot
│   │   └── page.tsx
│   └── @team/          // Parallel route slot
│       └── page.tsx
│
└── api/
    └── posts/
        └── route.ts    // API endpoint: GET/POST /api/posts
Note: Files named page.tsx, layout.tsx, loading.tsx, error.tsx, template.tsx, not-found.tsx, and route.ts are reserved filenames. All other files in app/ are collocated modules — they are not exposed as routes.

Nested Layouts

A layout.tsx wraps all pages in its segment and all nested segments. Layouts persist across navigations — they do not re-render when only a child route changes, making them ideal for navigation, sidebars, and auth shells. The children prop receives the nested layout or page.

// app/layout.tsx — root layout, required
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 | Acme App', default: 'Acme App' },
  description: 'The best app for acme things',
}

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <header><Nav /></header>
        <main>{children}</main>
        <footer><Footer /></footer>
      </body>
    </html>
  )
}

// app/dashboard/layout.tsx — nested layout for all /dashboard/* routes
import { Sidebar } from '@/components/Sidebar'
import { requireAuth } from '@/lib/auth'

export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
  // Server-side auth check — runs before rendering children
  const user = await requireAuth()

  return (
    <div className="dashboard-shell">
      <Sidebar user={user} />
      <div className="dashboard-content">
        {children}
      </div>
    </div>
  )
}

// app/dashboard/settings/layout.tsx — further nested
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
  return (
    <div>
      <nav>
        <a href="/dashboard/settings/profile">Profile</a>
        <a href="/dashboard/settings/billing">Billing</a>
        <a href="/dashboard/settings/security">Security</a>
      </nav>
      {children}
    </div>
  )
}

Loading UI and Streaming

A loading.tsx file at any route segment is automatically wrapped in a React Suspense boundary. Next.js streams the layout and the loading skeleton to the browser immediately while the page's async data fetching completes in the background — without any manual Suspense wiring.

// app/dashboard/loading.tsx
// Shown instantly while dashboard/page.tsx fetches data
export default function DashboardLoading() {
  return (
    <div className="dashboard-skeleton">
      <div className="skeleton h-8 w-48 mb-6" />
      <div className="grid grid-cols-3 gap-4">
        {Array.from({ length: 3 }).map((_, i) => (
          <div key={i} className="skeleton h-32 rounded-xl" />
        ))}
      </div>
    </div>
  )
}

// app/blog/[slug]/loading.tsx
export default function PostLoading() {
  return (
    <article>
      <div className="skeleton h-12 w-3/4 mb-4" />
      <div className="skeleton h-4 w-full mb-2" />
      <div className="skeleton h-4 w-full mb-2" />
      <div className="skeleton h-4 w-2/3" />
    </article>
  )
}

// Fine-grained streaming with manual Suspense in page.tsx
import { Suspense } from 'react'

// Each async component streams independently
async function PostBody({ slug }) {
  const post = await fetchPost(slug)   // slowest query
  return <article>{post.body}</article>
}

async function RelatedPosts({ slug }) {
  const posts = await fetchRelated(slug)
  return <aside>{posts.map(p => <a key={p.id} href={p.slug}>{p.title}</a>)}</aside>
}

async function Comments({ postId }) {
  const comments = await fetchComments(postId)
  return <ul>{comments.map(c => <li key={c.id}>{c.body}</li>)}</ul>
}

export default async function PostPage({ params }) {
  const { slug } = await params
  return (
    <div>
      <Suspense fallback={<ArticleSkeleton />}>
        <PostBody slug={slug} />
      </Suspense>
      <Suspense fallback={<RelatedSkeleton />}>
        <RelatedPosts slug={slug} />
      </Suspense>
      <Suspense fallback={<p>Loading comments...</p>}>
        <Comments postId={slug} />
      </Suspense>
    </div>
  )
}

Error UI and Recovery

An error.tsx file creates an automatic error boundary for its segment. It must be a Client Component because it uses reset — a function React provides to attempt re-rendering the segment. The root global-error.tsx catches errors in the root layout itself.

// app/dashboard/error.tsx — MUST be 'use client'
'use client'

import { useEffect } from 'react'

export default function DashboardError({
  error,
  reset,
}: {
  error: Error & { digest?: string }
  reset: () => void
}) {
  useEffect(() => {
    // Log to error tracking service
    console.error('Dashboard error:', error)
    Sentry.captureException(error)
  }, [error])

  return (
    <div className="error-container">
      <h2>Something went wrong in the dashboard</h2>
      <p className="text-muted">{error.message}</p>
      {error.digest && <code>Error ID: {error.digest}</code>}
      <button onClick={reset}>Try again</button>
      <a href="/">Go home</a>
    </div>
  )
}

// app/not-found.tsx — handles notFound() throws and 404s
import Link from 'next/link'

export default function NotFound() {
  return (
    <div>
      <h2>Page Not Found</h2>
      <p>The page you are looking for does not exist.</p>
      <Link href="/">Return Home</Link>
    </div>
  )
}

// Trigger not-found from a Server Component
import { notFound } from 'next/navigation'

async function PostPage({ params }) {
  const post = await fetchPost(params.slug)
  if (!post) notFound()   // renders not-found.tsx, returns 404
  return <article>{post.body}</article>
}

// app/global-error.tsx — catches errors in root layout
'use client'

export default function GlobalError({ error, reset }) {
  return (
    <html>
      <body>
        <h1>Something went wrong</h1>
        <button onClick={reset}>Try again</button>
      </body>
    </html>
  )
}

Server vs Client Components

All components in app/ are Server Components by default — they run only on the server, can access databases and secrets directly, and send zero JavaScript to the browser. Add 'use client' at the top of a file to make it and all its imports Client Components. The key rule: Server Components can import Client Components, but Client Components cannot import Server Components.

// Server Component (default) — no 'use client'
// Runs on server only: can use fs, databases, environment variables
import { db } from '@/lib/db'

export default async function UserList() {
  const users = await db.user.findMany()   // direct DB access, no API call needed
  return (
    <ul>
      {users.map(u => <li key={u.id}>{u.name}</li>)}
    </ul>
  )
}

// Client Component — must add directive
'use client'

import { useState } from 'react'  // hooks only work in Client Components

export function SearchBar({ onSearch }) {
  const [query, setQuery] = useState('')
  return (
    <input
      value={query}
      onChange={e => { setQuery(e.target.value); onSearch(e.target.value) }}
      placeholder="Search..."
    />
  )
}

// Composition pattern — Server Component wraps Client Component
// app/posts/page.tsx (Server Component)
import { SearchBar } from '@/components/SearchBar'   // Client Component — OK to import

export default async function PostsPage() {
  const initialPosts = await fetchPosts()
  return (
    <div>
      <SearchBar />          {/* Client — handles interaction */}
      <PostList posts={initialPosts} />   {/* Server — renders on server */}
    </div>
  )
}

// Passing Server Component as children to Client Component
'use client'
export function Modal({ children }) {
  const [open, setOpen] = useState(false)
  return (
    <div>
      <button onClick={() => setOpen(true)}>Open</button>
      {open && <div className="modal">{children}</div>}  {/* children can be Server Component */}
    </div>
  )
}

Data Fetching in Server Components

Server Components make data fetching simple: write async components and await directly inside. Next.js extends the native fetch API with caching and revalidation options. Multiple fetches in the same render tree are automatically deduplicated if they use the same URL.

// Static data — cached indefinitely (like getStaticProps)
async function StaticPage() {
  const data = await fetch('https://api.example.com/config', {
    cache: 'force-cache',
  }).then(r => r.json())
  return <div>{data.value}</div>
}

// ISR — revalidate every 60 seconds (like getStaticProps with revalidate)
async function BlogList() {
  const posts = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 },
  }).then(r => r.json())
  return <PostGrid posts={posts} />
}

// Dynamic — no cache, always fresh (like getServerSideProps)
async function LiveFeed() {
  const feed = await fetch('https://api.example.com/feed', {
    cache: 'no-store',
  }).then(r => r.json())
  return <Feed items={feed} />
}

// Route-level revalidation config
export const revalidate = 3600   // revalidate entire route every hour
export const dynamic = 'force-dynamic'  // always dynamic

// Parallel data fetching — Promise.all prevents waterfall
export default async function DashboardPage() {
  const [user, stats, activity] = await Promise.all([
    fetchUser(),
    fetchStats(),
    fetchActivity(),
  ])

  return (
    <div>
      <UserCard user={user} />
      <StatsGrid stats={stats} />
      <ActivityFeed items={activity} />
    </div>
  )
}

// Server Actions — mutations from Server Components
'use server'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const body = formData.get('body') as string

  await db.post.create({ data: { title, body } })
  revalidatePath('/blog')
  redirect('/blog')
}

// Use in a form — no API route needed
export default function NewPostForm() {
  return (
    <form action={createPost}>
      <input name="title" />
      <textarea name="body" />
      <button type="submit">Publish</button>
    </form>
  )
}

Route Groups and Parallel Routes

Route groups (folders wrapped in parentheses) let you organise routes without affecting the URL. They are perfect for sharing layouts between non-adjacent routes — for example, a shared auth layout for login and signup pages, while the marketing pages use a different layout. Parallel routes render multiple pages simultaneously in the same layout using named slots prefixed with @.

// Route groups — (auth) doesn't appear in the URL
app/
├── (auth)/
│   ├── layout.tsx      // Auth shell — used for /login and /signup only
│   ├── login/
│   │   └── page.tsx    // URL: /login
│   └── signup/
│       └── page.tsx    // URL: /signup
├── (marketing)/
│   ├── layout.tsx      // Marketing shell
│   ├── page.tsx        // URL: /
│   └── about/
│       └── page.tsx    // URL: /about

// Parallel routes — render two pages in one layout
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,   // @analytics slot
  team,        // @team slot
}: {
  children: React.ReactNode
  analytics: React.ReactNode
  team: React.ReactNode
}) {
  return (
    <div className="dashboard-grid">
      <main>{children}</main>
      <aside>
        <section>{analytics}</section>
        <section>{team}</section>
      </aside>
    </div>
  )
}

// Intercepting routes — modal patterns
// app/photos/[id]/page.tsx         — full page on direct visit
// app/@modal/(.)photos/[id]/page.tsx — modal when navigating from feed
// The (.) prefix intercepts same-level routes

// Dynamic route params
// app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}) {
  const { slug } = await params
  const { ref } = await searchParams   // ?ref=twitter
  const post = await fetchPost(slug)
  return <article><h1>{post.title}</h1></article>
}

// Generate static params for ISR
export async function generateStaticParams() {
  const posts = await fetchAllPosts()
  return posts.map(post => ({ slug: post.slug }))
}

Metadata API and SEO

The App Router replaces <Head> with a typed Metadata API. Export a metadata object for static metadata or a generateMetadata async function for dynamic metadata. Next.js merges metadata from all layouts and the page, with child values overriding parents.

// Static metadata — export from layout.tsx or page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'Blog',
  description: 'Read the latest articles',
  openGraph: {
    title: 'Blog | Acme',
    description: 'Read the latest articles',
    url: 'https://acme.com/blog',
    siteName: 'Acme',
    images: [{ url: 'https://acme.com/og.png', width: 1200, height: 630 }],
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Blog | Acme',
    images: ['https://acme.com/og.png'],
  },
  robots: { index: true, follow: true },
  canonical: 'https://acme.com/blog',
}

// Dynamic metadata — fetch data for per-post SEO
export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const post = await fetchPost(slug)

  if (!post) return { title: 'Post Not Found' }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [{ url: post.coverImage, width: 1200, height: 630, alt: post.title }],
    },
    alternates: {
      canonical: `https://acme.com/blog/${slug}`,
    },
  }
}