Skip to content
C Codeloom
Next.js

The Next.js App Router: Pages, Layouts, and Routing

A practical guide to the Next.js App Router — file-based routing, nested layouts, dynamic segments, client-side navigation with Link and useRouter, and the special loading and error files.

·9 min read · By Yash Kesharwani
Beginner 12 min read

What you'll learn

  • How file-system routing works in the app/ directory
  • The difference between page.tsx and layout.tsx
  • How nested layouts compose and where state survives
  • Dynamic segments with [slug] and catch-all routes
  • Client-side navigation with next/link and useRouter
  • The special files loading.tsx, error.tsx, and not-found.tsx

Prerequisites

The App Router is the heart of modern Next.js. Once you understand the four or five conventions it uses, you can read any production codebase. This post walks through the file-system conventions, layouts, dynamic routes, and the navigation hooks that tie them together.

File-system routing

Every route in the App Router is a folder under app/. The URL path is the folder path.

app/
├── page.tsx               // /
├── about/
│   └── page.tsx           // /about
├── blog/
│   ├── page.tsx           // /blog
│   └── [slug]/
│       └── page.tsx       // /blog/:slug
└── settings/
    ├── page.tsx           // /settings
    ├── profile/
    │   └── page.tsx       // /settings/profile
    └── billing/
        └── page.tsx       // /settings/billing

Three rules govern the tree:

  1. A folder named anything plain (about, blog, settings) becomes a URL segment.
  2. A folder named [name] becomes a dynamic segment.
  3. A page.tsx inside a folder makes that path a real URL. Without one, the folder is just a container.

A folder without a page.tsx is not routable. You can use it to group code without creating a URL.

Pages

A page is the unique UI for a route. It is a default export from page.tsx.

// app/about/page.tsx
export default function AboutPage() {
  return (
    <main>
      <h1>About</h1>
      <p>This page is rendered for the /about route.</p>
    </main>
  );
}

By default, pages are React Server Components. They run on the server, can be async, and ship no JavaScript for themselves. We dig into that in Next.js Server Components Explained.

Layouts

A layout wraps every page beneath it. It is a default export from layout.tsx that accepts a children prop.

// app/layout.tsx — the root layout
import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <header>My Site</header>
        {children}
        <footer>© 2026</footer>
      </body>
    </html>
  );
}

The root layout is mandatory and is the only place where <html> and <body> may appear.

Layouts can nest. A layout at app/dashboard/layout.tsx wraps every page under /dashboard/* and is itself wrapped by the root layout.

// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <aside>Sidebar</aside>
      <section>{children}</section>
    </div>
  );
}

Navigating between two pages that share a layout (/dashboard/users to /dashboard/billing) does not unmount the layout. State inside it survives. This is the App Router’s biggest UX win over a SPA that re-renders an entire shell on every navigation.

Try it yourself. Create a layout at app/dashboard/layout.tsx that includes a client component with useState (a small counter). Create two pages, dashboard/users/page.tsx and dashboard/billing/page.tsx. Navigate between them with <Link>. Confirm the counter keeps its value. The layout did not re-render — only the page did.

Nested routes

Folders nest as deeply as you want. The URL is the path.

app/
└── shop/
    ├── layout.tsx                 // shared chrome for /shop/*
    ├── page.tsx                   // /shop
    └── product/
        └── [id]/
            ├── page.tsx           // /shop/product/:id
            └── reviews/
                └── page.tsx       // /shop/product/:id/reviews

Every layout above a page wraps it. The /shop/product/123/reviews request mounts: root layout → shop layout → reviews page.

Dynamic segments

A folder named [name] becomes a parameter. The page receives it through params.

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

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

In Next.js 15, params is a Promise you await. That change makes the API consistent with searchParams and unlocks streaming. If you see older code that destructures params directly, it predates 15.

A few related conventions:

  • [slug] matches a single segment
  • [...slug] is a catch-all that matches everything after, e.g. /docs/a/b/c
  • [[...slug]] is an optional catch-all that also matches the empty path

You will use [slug] 95% of the time. The other two show up in docs sites and CMS-backed routes.

Linking between pages

The browser’s default <a> tag triggers a full page reload. Inside a Next.js app, use next/link instead.

// app/page.tsx
import Link from 'next/link';

export default function Home() {
  return (
    <nav>
      <Link href="/">Home</Link>
      <Link href="/about">About</Link>
      <Link href="/blog/hello-world">First Post</Link>
    </nav>
  );
}

<Link> renders an <a> but intercepts the click and uses the router. It also prefetches the destination’s code in the background when the link enters the viewport, so navigation feels instant.

Use a plain <a> only when linking to a different origin or when you genuinely want a full page reload.

Programmatic navigation with useRouter

Sometimes you need to navigate from code — after a form submit, on a timer, in response to an event. Use useRouter from next/navigation.

'use client';

import { useRouter } from 'next/navigation';

export default function LogoutButton() {
  const router = useRouter();

  function handleClick() {
    // ... clear session ...
    router.push('/login');
  }

  return <button onClick={handleClick}>Log out</button>;
}

Two things to note:

  • The import is next/navigation, not next/router. The latter is the old Pages Router API.
  • useRouter is a hook, so the component must be a Client Component — hence 'use client' at the top of the file.

The router object has a small surface: push, replace, back, forward, and refresh. Most code only ever calls push.

Reading the current URL

Two more hooks from next/navigation:

'use client';

import { usePathname, useSearchParams } from 'next/navigation';

export default function Debug() {
  const pathname = usePathname();   // "/blog/hello-world"
  const search = useSearchParams(); // ReadonlyURLSearchParams

  return (
    <pre>
      {pathname}?{search.toString()}
    </pre>
  );
}

usePathname is the standard way to highlight the current item in a nav. useSearchParams reads the query string reactively.

For Server Components, you get the same information through props — searchParams is passed to every page that needs it:

// app/search/page.tsx
export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  return <p>You searched for: {q ?? '(nothing)'}</p>;
}

Loading UI

A file named loading.tsx next to a page.tsx is shown while the page’s Server Component is still fetching data.

// app/blog/[slug]/loading.tsx
export default function Loading() {
  return <p>Loading post...</p>;
}

You write zero suspense boilerplate. Next.js wraps the page in a <Suspense> boundary using loading.tsx as the fallback. The result is a skeleton that appears immediately while the page streams in.

Error UI

A file named error.tsx catches runtime errors in the page or its children. It must be a Client Component because it uses an error boundary.

// app/blog/[slug]/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong.</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

reset re-runs the segment. It is genuinely useful for transient failures.

There is also a special global-error.tsx at the root for errors in the root layout itself, and a not-found.tsx for 404s.

// app/blog/[slug]/not-found.tsx
export default function NotFound() {
  return <p>Post not found.</p>;
}

Call notFound() from next/navigation in a Server Component to trigger it:

import { notFound } from 'next/navigation';

const post = await getPost(slug);
if (!post) notFound();

Try it yourself. In a blog/[slug]/page.tsx Server Component, fetch a fake post from https://jsonplaceholder.typicode.com/posts/{slug}. Add a loading.tsx that renders a spinner. Add an error.tsx that catches network failures. Now break the URL on purpose by visiting an invalid post id and confirm your error UI shows up. The fact that all of this works without manual error-boundary wiring is one of the App Router’s quiet superpowers.

Route groups

Sometimes you want to organize files without affecting the URL. A folder wrapped in parentheses is a route group.

app/
├── (marketing)/
│   ├── layout.tsx           // shared marketing chrome
│   ├── page.tsx             // /
│   └── pricing/
│       └── page.tsx         // /pricing
└── (app)/
    ├── layout.tsx           // shared logged-in chrome
    └── dashboard/
        └── page.tsx         // /dashboard

(marketing) and (app) do not appear in URLs. They let you have two independent layouts at the same depth — useful when your marketing pages and your app shell look completely different.

Private folders

A folder prefixed with an underscore (_components/, _lib/) is opted out of routing entirely. Use them for files you want to colocate next to a route but never expose as a URL.

A complete small example

A blog index plus a post route, fully wired:

// app/blog/page.tsx — index
import Link from 'next/link';

const posts = [
  { slug: 'hello-world', title: 'Hello World' },
  { slug: 'second-post', title: 'Second Post' },
];

export default function BlogIndex() {
  return (
    <ul>
      {posts.map((p) => (
        <li key={p.slug}>
          <Link href={`/blog/${p.slug}`}>{p.title}</Link>
        </li>
      ))}
    </ul>
  );
}
// app/blog/[slug]/page.tsx — detail
import { notFound } from 'next/navigation';

const posts: Record<string, { title: string; body: string }> = {
  'hello-world': { title: 'Hello World', body: 'First post.' },
  'second-post': { title: 'Second Post', body: 'And another.' },
};

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = posts[slug];
  if (!post) notFound();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </article>
  );
}

Two files, one dynamic route, navigation between them, and a 404 when the slug is missing. That is the full vocabulary you will reach for in 90% of routes you write.

Recap

You now know:

  • A page.tsx inside a folder defines a route at that path
  • layout.tsx wraps every page below it and persists across navigation
  • [slug] is a dynamic segment; the value arrives through params (a Promise in Next 15)
  • <Link> from next/link does client-side navigation with prefetching
  • useRouter, usePathname, and useSearchParams from next/navigation cover the client-side router
  • loading.tsx, error.tsx, and not-found.tsx are special files that wire up Suspense, error boundaries, and 404s for free
  • Route groups (name)/ organize files without affecting URLs

Next steps

The next post explains the most novel idea in modern Next.js — React Server Components, the "use client" boundary, and where to fetch data.

→ Next: Next.js Server Components Explained

Questions or feedback? Email codeloomdevv@gmail.com.