Back to blog
Next.js
14 min read
Next.js
App Router

Next.js 15 App Router: Complete Beginner's Guide

Learn the Next.js 15 App Router from scratch. This beginner-friendly tutorial covers layouts, pages, loading states, error handling, route groups, server vs client components, data fetching, and the metadata API.

SB

SERP Blocks Team

Product

Next.js 15 App Router: Complete Beginner's Guide cover

The Next.js App Router changed how React applications are structured, and if you are coming from the old Pages Router — or from React development without a framework — the mental model takes some adjustment. Layouts that persist across navigations, server components that run only on the server, streaming that sends UI to the browser in chunks, and a file-system routing convention that replaces manual route configuration entirely.

This guide covers every foundational concept in the Next.js 15 App Router. By the end, you will understand how to structure a project, create pages and layouts, handle loading and error states, fetch data, manage metadata for SEO, and decide when to use server versus client components. No prior Next.js experience required — just familiarity with React basics.

What Is the App Router?

Next.js has two routing systems: the Pages Router (the original, introduced in 2016) and the App Router (introduced in Next.js 13, stable since Next.js 14, and the default in Next.js 15). The App Router lives in the app/ directory and uses React Server Components as its foundation.

The key differences from the Pages Router:

  • Server Components by default — components render on the server unless you explicitly opt into client-side rendering

  • Nested layouts — layouts wrap pages and persist across navigations without re-rendering

  • Colocation — components, styles, tests, and utilities can live alongside the routes they belong to

  • Streaming — pages can send content to the browser incrementally, improving perceived performance

  • Built-in loading and error UI — special files handle loading states and error boundaries automatically

If you are starting a new Next.js project today, the App Router is the path forward. The Pages Router still works and is supported, but all new features and improvements target the App Router.

Setting Up a Next.js 15 Project

Start by creating a new project:

npx create-next-app@latest my-app

When the CLI asks questions, select these options:

TypeScript? Yes
ESLint? Yes
Tailwind CSS? Yes
src/ directory? Yes
App Router? Yes
Turbopack? Yes
Customize import alias? No

This creates a project with the App Router enabled, TypeScript configured, and Tailwind CSS ready to use. Navigate into the project and start the development server:

cd my-app
npm run dev

Open http://localhost:3000 to verify the default page loads. The project structure will look like this:

my-app/
├── src/
│   └── app/
│       ├── layout.tsx      # Root layout
│       ├── page.tsx         # Home page (/)
│       ├── globals.css      # Global styles
│       └── favicon.ico
├── public/                  # Static assets
├── next.config.ts           # Next.js configuration
├── tailwind.config.ts       # Tailwind configuration
├── tsconfig.json            # TypeScript configuration
└── package.json

Everything inside src/app/ is the App Router territory. The routing is defined by the file system — folders become URL segments, and special files (page.tsx, layout.tsx, loading.tsx, error.tsx) control what renders at each segment.

Pages and Routing

Every route in the App Router needs a page.tsx file. The folder name determines the URL path:

src/app/page.tsx              →  /
src/app/about/page.tsx        →  /about
src/app/blog/page.tsx         →  /blog
src/app/blog/[slug]/page.tsx  →  /blog/any-slug-here

A basic page component is just a React component that is the default export:

// src/app/about/page.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>About Us</h1>
      <p>Welcome to our application.</p>
    </main>
  );
}

That is it. No router configuration, no imports from next/router, no wrapper components. Create the folder, add page.tsx, and the route exists.

Dynamic Routes

Square brackets in folder names create dynamic segments:

// src/app/blog/[slug]/page.tsx
export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  return (
    <article>
      <h1>Blog Post: {slug}</h1>
    </article>
  );
}

In Next.js 15, params is a Promise that must be awaited. This is a change from earlier versions where params was a plain object. The URL /blog/my-first-post sets slug to "my-first-post".

Catch-All Routes

Use [...slug] for catch-all segments that match multiple path levels:

src/app/docs/[...slug]/page.tsx  →  /docs/a, /docs/a/b, /docs/a/b/c

The slug parameter becomes an array: ["a", "b", "c"] for the path /docs/a/b/c. This pattern is useful for documentation sites, wikis, or any deeply nested content structure.

Layouts

Layouts are the App Router's most powerful feature for building consistent UI. A layout wraps its child pages and persists across navigations — it does not re-render when the user moves between pages that share the same layout.

The Root Layout

Every Next.js app needs a root layout at src/app/layout.tsx. This is the outermost shell that wraps the entire application:

// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "My Application",
  description: "Built with Next.js 15",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header>
          <nav>{/* Navigation here */}</nav>
        </header>
        {children}
        <footer>{/* Footer here */}</footer>
      </body>
    </html>
  );
}

The root layout must include <html> and <body> tags. The {children} prop is where the current page renders. Navigation, footers, sidebars, and other persistent UI elements go in the layout.

Nested Layouts

Any route segment can have its own layout, and layouts nest automatically:

// src/app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex">
      <aside className="w-64">
        {/* Dashboard sidebar navigation */}
      </aside>
      <main className="flex-1">{children}</main>
    </div>
  );
}

Pages inside src/app/dashboard/ are wrapped by this layout, which is itself wrapped by the root layout. The dashboard sidebar stays in place as users navigate between /dashboard/analytics, /dashboard/settings, and other sub-pages. This is how you build interfaces with persistent sidebars, tab bars, or secondary navigation without re-rendering them on every page change.

Building these layout structures by hand takes significant time. If you want to skip the design work and start with production-ready layouts, SERP Blocks includes pre-built dashboard layouts with sidebars, navbar components for top navigation, and footer designs — all built with Shadcn UI and Tailwind CSS, ready to drop into your App Router layouts.

Route Groups

Route groups let you organize routes into logical groups without affecting the URL structure. Wrap a folder name in parentheses and it becomes invisible in the URL:

src/app/(marketing)/page.tsx       →  /
src/app/(marketing)/about/page.tsx →  /about
src/app/(marketing)/pricing/page.tsx → /pricing
src/app/(dashboard)/dashboard/page.tsx → /dashboard

The (marketing) and (dashboard) folders exist only for organization. Each group can have its own layout, so your marketing pages can use a full-width layout with a hero section while your dashboard uses a sidebar layout — and the layouts stay completely separate.

src/app/
├── (marketing)/
│   ├── layout.tsx          # Marketing layout (navbar + footer)
│   ├── page.tsx            # / (home page)
│   ├── about/page.tsx      # /about
│   └── pricing/page.tsx    # /pricing
└── (dashboard)/
    ├── layout.tsx          # Dashboard layout (sidebar)
    ├── dashboard/page.tsx  # /dashboard
    └── settings/page.tsx   # /settings

Route groups are essential for real-world applications. Most apps have at least two layout zones — a public marketing site and an authenticated application area. Route groups make this separation clean.

Loading States

The App Router has a built-in convention for loading UI. Add a loading.tsx file to any route segment and it automatically wraps the page in a React Suspense boundary:

// src/app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="flex items-center justify-center h-64">
      <div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
    </div>
  );
}

When the dashboard page is loading (fetching data, waiting for a slow database query), Next.js shows this loading component instead. Once the page finishes loading, it replaces the loading UI seamlessly. The layout remains visible and interactive throughout — only the page content area shows the loading state.

This works because of React's streaming architecture. The server sends the layout immediately, starts rendering the page, and streams the result when it is ready. Users see a responsive interface from the first moment, not a blank screen.

Error Handling

Similar to loading states, the App Router provides a convention for error handling:

// src/app/dashboard/error.tsx
"use client";

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-16">
      <h2 className="text-2xl font-bold">Something went wrong</h2>
      <p className="text-muted-foreground mt-2">{error.message}</p>
      <button onClick={() => reset()} className="mt-4 px-4 py-2 bg-primary text-white rounded">
        Try again
      </button>
    </div>
  );
}

Error components must be client components (note the "use client" directive). They receive the error object and a reset function that lets users retry the failed render. The error boundary catches errors in the page and its children, but the layout above it stays intact — the user can still navigate away using the navigation in the layout.

For a global error boundary that catches errors in the root layout itself, create src/app/global-error.tsx. This is the last line of defense and must render its own <html> and <body> tags since it replaces the root layout when triggered.

If you want polished error pages rather than building them from scratch, SERP Blocks has 11 error page designs covering 404, 500, maintenance mode, and other common error states.

Server Components vs Client Components

This is the concept that trips up most beginners. In the App Router, every component is a Server Component by default. Server Components:

  • Run only on the server

  • Can directly access databases, file systems, and environment variables

  • Cannot use React hooks (useState, useEffect, useRef)

  • Cannot use browser APIs (window, document, localStorage)

  • Cannot use event handlers (onClick, onChange)

  • Send only their rendered HTML to the browser, not their JavaScript

To make a component interactive, add the "use client" directive at the top of the file:

"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

When to Use Each

Use Server Components (the default) for:

  • Pages that display data from a database or API

  • Static content like marketing pages, blog posts, documentation

  • Components that do not need interactivity

  • Anything that benefits from not sending JavaScript to the browser

Use Client Components for:

  • Interactive UI: forms, toggles, dropdowns, modals, tabs

  • Components that use React state or effects

  • Components that need browser APIs

  • Components using third-party libraries that require client-side rendering

The best practice is to keep the boundary as low as possible. Make the page a Server Component and only wrap the specific interactive pieces in "use client". A common pattern:

// src/app/products/page.tsx (Server Component)
import { ProductFilter } from "./product-filter"; // Client Component
import { getProducts } from "@/lib/data";

export default async function ProductsPage() {
  const products = await getProducts();

  return (
    <main>
      <h1>Products</h1>
      <ProductFilter initialProducts={products} />
    </main>
  );
}

The page fetches data on the server (no API layer needed, no loading spinners for the initial data), then passes it to an interactive client component that handles filtering and sorting.

Data Fetching

In the App Router, data fetching is straightforward because Server Components can be async functions:

// src/app/blog/page.tsx
async function getPosts() {
  const res = await fetch("https://api.example.com/posts", {
    next: { revalidate: 3600 }, // Revalidate every hour
  });
  return res.json();
}

export default async function BlogPage() {
  const posts = await getPosts();

  return (
    <main>
      <h1>Blog</h1>
      {posts.map((post: any) => (
        <article key={post.id}>
          <h2>{post.title}</h2>
          <p>{post.excerpt}</p>
        </article>
      ))}
    </main>
  );
}

No useEffect, no useState for loading states, no getServerSideProps. Just async/await at the component level. Next.js extends the native fetch API with caching and revalidation options:

  • { cache: "force-cache" } — cache the response indefinitely (static data)

  • { cache: "no-store" } — fetch fresh data on every request (dynamic data)

  • { next: { revalidate: 60 } } — cache for 60 seconds, then revalidate in the background (ISR)

Direct Database Access

Since Server Components run on the server, you can query your database directly without building an API layer:

import { db } from "@/lib/database";

export default async function UsersPage() {
  const users = await db.query("SELECT * FROM users LIMIT 50");

  return (
    <table>
      <tbody>
        {users.map((user) => (
          <tr key={user.id}>
            <td>{user.name}</td>
            <td>{user.email}</td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

This eliminates an entire layer of abstraction. No REST endpoints, no GraphQL resolvers, no client-side data fetching libraries for initial page data. The database query runs during server rendering and the result is sent as HTML.

The Metadata API

SEO metadata is defined per-page using either a static metadata export or a dynamic generateMetadata function:

// Static metadata
import type { Metadata } from "next";

export const metadata: Metadata = {
  title: "About Us",
  description: "Learn about our team and mission.",
  openGraph: {
    title: "About Us",
    description: "Learn about our team and mission.",
    type: "website",
  },
};

export default function AboutPage() {
  return <main>{/* Page content */}</main>;
}

For dynamic pages where metadata depends on the route parameters:

// Dynamic metadata
import type { Metadata } from "next";

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>;
}): Promise<Metadata> {
  const { slug } = await params;
  const post = await getPost(slug);

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage],
    },
  };
}

export default async function BlogPost({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await getPost(slug);

  return <article>{/* Post content */}</article>;
}

Next.js automatically deduplicates the data fetch — if both generateMetadata and the page component call getPost(slug), the request is only made once.

Metadata also supports a template pattern in layouts for consistent title formatting:

// src/app/layout.tsx
export const metadata: Metadata = {
  title: {
    default: "My App",
    template: "%s | My App",
  },
};

Now every page that sets title: "About Us" will render as "About Us | My App" in the browser tab.

Server Actions

Server Actions let you define server-side functions that can be called directly from client components — no API routes needed:

// src/app/contact/actions.ts
"use server";

export async function submitContactForm(formData: FormData) {
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;
  const message = formData.get("message") as string;

  await db.insert({ name, email, message }).into("contact_submissions");

  return { success: true };
}
// src/app/contact/page.tsx
import { submitContactForm } from "./actions";

export default function ContactPage() {
  return (
    <form action={submitContactForm}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

The form works without JavaScript (progressive enhancement), and when JavaScript is available, the submission happens without a full page reload. Server Actions are the recommended way to handle form submissions and data mutations in the App Router.

For production-ready contact forms, SERP Blocks offers 33 contact page designs that pair beautifully with Server Actions — the UI is ready, you just wire up the server function.

Putting It All Together

Here is a realistic project structure for a small application that uses everything covered in this guide:

src/app/
├── layout.tsx                    # Root layout (html, body, global nav)
├── page.tsx                      # Home page
├── loading.tsx                   # Global loading state
├── error.tsx                     # Global error boundary
├── global-error.tsx              # Root error boundary
│
├── (marketing)/
│   ├── layout.tsx                # Marketing layout (header + footer)
│   ├── about/page.tsx            # /about
│   ├── pricing/page.tsx          # /pricing
│   └── blog/
│       ├── page.tsx              # /blog (list)
│       └── [slug]/page.tsx       # /blog/:slug (single post)
│
├── (app)/
│   ├── layout.tsx                # App layout (sidebar + topbar)
│   ├── dashboard/
│   │   ├── page.tsx              # /dashboard
│   │   ├── loading.tsx           # Dashboard loading state
│   │   └── error.tsx             # Dashboard error boundary
│   └── settings/
│       └── page.tsx              # /settings
│
└── api/
    └── webhooks/
        └── route.ts              # API route for webhooks

The marketing section has its own layout with a navbar and footer. The app section has a dashboard layout with an admin sidebar. Both share the root layout. Each section can have independent loading and error states.

Common Patterns and Best Practices

Parallel Data Fetching

When a page needs data from multiple sources, fetch them in parallel:

export default async function DashboardPage() {
  const [stats, recentOrders, notifications] = await Promise.all([
    getStats(),
    getRecentOrders(),
    getNotifications(),
  ]);

  return (
    <div>
      <StatsGrid data={stats} />
      <RecentOrdersTable orders={recentOrders} />
      <NotificationsFeed items={notifications} />
    </div>
  );
}

Streaming with Suspense

For pages where some data loads faster than others, wrap slow sections in Suspense boundaries:

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <StatsSection />
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </div>
  );
}

The stats section and analytics chart load independently. Whichever finishes first streams to the browser first. Users see content appearing progressively rather than waiting for everything to load.

Keeping Client Components Small

Push the "use client" boundary as far down the component tree as possible. Instead of making an entire page a client component because it has one interactive element, extract just that element:

// page.tsx (Server Component — fetches data, renders static content)
import { LikeButton } from "./like-button"; // Only this is a Client Component

export default async function PostPage({ params }) {
  const { slug } = await params;
  const post = await getPost(slug);

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
      <LikeButton postId={post.id} initialCount={post.likes} />
    </article>
  );
}

Building Beautiful Pages Faster

Understanding the App Router gives you the architectural foundation. You know how to structure routes, create layouts, handle loading and errors, fetch data, and manage server versus client boundaries. The next challenge is building the actual UI — the hero sections, feature grids, pricing tables, testimonial carousels, dashboards, and every other section that makes up a real application.

This is where pre-built component blocks save enormous amounts of time. Instead of designing and coding every section from scratch, you start with a professionally designed block, drop it into your App Router page, and customize the content.

SERP Blocks has over 1,200 blocks across 50+ categories, all built with Shadcn UI and Tailwind CSS. To give you a sense of the coverage:

There are 60 free blocks to get started. The full Pro library is a one-time purchase — no recurring subscription. Every block is production-ready code that drops directly into the App Router project structure you just learned. Copy a hero block into your (marketing)/page.tsx, add a dashboard block to your (app)/dashboard/page.tsx, and you have a polished application in a fraction of the time.

The App Router handles the architecture. Blocks handle the design. Together, you ship faster.