| |

React 92 ⚛️ Framework Setup with Next.js App Router

⏱️ Estimated read: 17 min | |

The Next.js App Router is not a configuration layer on top of React. It is a file-system router built on React Server Components, where the folder structure under app/ defines the routes, layouts persist across navigations, and every component is a Server Component by default until a 'use client' directive marks the boundary where interactivity begins . Setup is therefore less about installing dependencies and more about understanding the conventions: which files create routes, which files wrap them in shared UI, and which files handle the cases where data is still loading or an error occurred.

The create-next-app CLI scaffolds the project with TypeScript, ESLint, and Tailwind CSS configured, and the resulting app/ directory contains the root layout and the home page. The root layout is the only required file; it must render <html> and <body>, and it wraps every route in the application . Every other route is a folder with a page.tsx file. A folder without a page.tsx is not routable, and a folder with parentheses in its name—a route group—organizes code without affecting the URL .

This chapter covers the scaffolding command and project structure, the routing files that define pages, layouts, loading states, and errors, the Server and Client Component boundary that determines what ships to the browser, and the data fetching and caching defaults that the App Router enforces at build time. It assumes familiarity with React and the distinction between server-rendered and client-rendered UI.

Key point: The App Router is file-system routing with Server Components as the default. A folder becomes a route when it contains a page.tsx. A layout.tsx wraps the route and its children and persists across navigation. A 'use client' directive marks the boundary where components become interactive and ship JavaScript to the browser. The setup is the convention, not a configuration file.


Why the App Router requires a different setup mental model

The Pages Router configured rendering per page. In the Pages Router, a page exported getStaticProps for static generation or getServerSideProps for server-side rendering, and the choice was explicit in the file . The App Router removes those exports. A page is an async function that fetches data directly, and whether the route is static or dynamic depends on what the fetch does and whether the result is cached . The rendering strategy is a consequence of the data access pattern, not a configuration flag.

Server Components change what “setup” means. A default Next.js component runs on the server and ships zero JavaScript to the browser. It can read from the database, access the filesystem, and use environment variables that never reach the client . The 'use client' directive is the setup decision: it marks the module graph boundary where the component and its imports become part of the client bundle . Adding it at the top of a page makes the entire page interactive; adding it to a leaf component keeps the page server-rendered and only the leaf interactive .

Layouts are the shared shell, not a wrapper component. A layout.tsx receives a children prop and renders it inside shared UI. The layout does not re-render on navigation between its children; it persists. A template.tsx is the alternative that re-mounts on navigation, which is useful for enter/exit animations but should be used sparingly because it discards the persistence benefit . The setup convention is that the root layout is mandatory and wraps every route, and nested layouts wrap their segment and its descendants.

The trade-off. The App Router’s defaults are opinionated: Server Components by default, data fetching without useEffect, caching behavior that the build step enforces. The setup requires understanding these defaults, because the build will fail if a route accesses uncached data without a <Suspense> boundary or a 'use cache' directive . The Pages Router allowed more flexibility with less upfront understanding. The App Router requires the understanding upfront.


a. Scaffolding and the project structure

create-next-app is the scaffolding command. It prompts for TypeScript, ESLint, Tailwind CSS, and the src/ directory option, or accepts flags to skip the prompts. The resulting structure places routing files under app/ and static assets under public/ .

# Scaffold a new Next.js application with the App Router.
npx create-next-app@latest my-app

# Non-interactive with common defaults.
npx create-next-app@latest my-app --typescript --eslint --tailwind --app

The top-level folders follow a convention. app/ holds the routing tree. public/ holds static assets served at the root URL. src/ is an optional wrapper for the application code, separating it from configuration files at the project root . Configuration files include next.config.ts for Next.js settings, tsconfig.json for TypeScript, and package.json for dependencies and scripts.

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

The root layout is the only file the App Router requires. It must render <html> and <body>, and it receives children which is the route being rendered. The root layout can fetch data, but it should not be made interactive with 'use client' unless every route needs client-side state .


b. Routing files: pages, layouts, loading, and errors

A folder becomes a public route when it contains a page.tsx file. The folder name becomes the URL segment. Nested folders create nested URL paths. A folder without a page.tsx is not routable, which is why private folders prefixed with _ and route groups wrapped in parentheses can hold components and utilities without becoming routes .

// app/page.tsx — the home route (/).
export default function HomePage() {
  return <h1>Welcome</h1>;
}

// app/blog/page.tsx — the /blog route.
export default function BlogPage() {
  return <h1>Blog</h1>;
}

A layout.tsx wraps its segment and all descendants. The root layout wraps everything. A nested layout in app/blog/layout.tsx wraps /blog and any route beneath it. The layout receives children and renders it inside shared UI. The layout persists across navigation between its children, so it does not re-render when the child route changes .

// app/blog/layout.tsx — wraps /blog and descendants.
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      <nav>Blog Navigation</nav>
      {children}
    </section>
  );
}

A loading.tsx file becomes an automatic <Suspense> boundary around the page. Next.js renders the loading component as the fallback while the page’s data resolves, and then replaces it with the completed content . An error.tsx file becomes an error boundary for the segment. It must be a Client Component because it uses reset() to attempt recovery .

// app/blog/loading.tsx — automatic Suspense fallback.
export default function Loading() {
  return <p>Loading posts...</p>;
}

// app/blog/error.tsx — segment error boundary.
'use client';

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

c. Server and Client Components: the 'use client' boundary

Every component in the app/ directory is a Server Component by default. It runs on the server, ships no JavaScript to the browser, and can be an async function that awaits data directly. It cannot use React hooks like useState or useEffect, cannot access browser APIs like window or localStorage, and cannot use React context .

The 'use client' directive at the top of a file marks the boundary. The component and everything it imports become part of the client bundle. The component is still server-rendered for the initial HTML, but it is hydrated on the client to become interactive .

// app/components/search.tsx — interactive, ships JS.
'use client';

import { useState } from 'react';

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

The optimization pattern is to push the 'use client' boundary as deep as possible. A page that is mostly static with one interactive search bar should be a Server Component that imports the search bar as a Client Component. Marking the entire page as 'use client' ships the whole page’s JavaScript to the browser unnecessarily .

Server Components can pass data to Client Components through props, but the props must be serializable. They can also pass Server Components as children to Client Components, which allows a server-rendered cart to be nested inside a client-rendered modal that manages visibility state .


d. Data fetching and the caching defaults

A Server Component fetches data with async/await. There is no useEffect and no loading state managed by the component; the framework handles the pending state through the loading.tsx boundary or an explicit <Suspense> .

// app/products/page.tsx — async Server Component.
export default async function ProductsPage() {
  const res = await fetch('https://api.example.com/products');
  const products = await res.json();

  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

The build step enforces the caching model. If a route accesses uncached or request-time data—fetch without a cache directive, cookies(), headers(), params, or searchParams—outside a <Suspense> boundary, the build fails with an error explaining that the route cannot be prerendered . The fix is one of three options: wrap the data access in <Suspense> to stream the dynamic part while the rest of the page prerenders, cache the access with 'use cache', or opt the route into blocking dynamic rendering with export const instant = false .

// Explicit Suspense boundary for request-time data.
import { Suspense } from 'react';

export default function Page() {
  return (
    <section>
      <h1>Products</h1>
      <Suspense fallback={<p>Loading...</p>}>
        <ProductList />
      </Suspense>
    </section>
  );
}

The cacheComponents flag in next.config.ts enables the 'use cache' directive and Partial Prerendering as the default rendering model. The static shell of a route is prerendered and served immediately, while dynamic content streams in through Suspense boundaries . Without the flag, the build enforces the same constraints through the prerender error, but the opt-in is explicit.


Complete Example Session

// ============================================
// PART 1: ROOT LAYOUT (REQUIRED)
// ============================================
// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';

export const metadata: Metadata = {
  title: 'My App',
  description: 'Built with Next.js App Router',
};

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

// ============================================
// PART 2: HOME PAGE
// ============================================
// app/page.tsx
export default function HomePage() {
  return <h1>Home</h1>;
}

// ============================================
// PART 3: NESTED LAYOUT AND PAGE
// ============================================
// app/blog/layout.tsx
export default function BlogLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      <nav>Blog</nav>
      {children}
    </section>
  );
}

// app/blog/page.tsx
export default function BlogPage() {
  return <h2>All Posts</h2>;
}

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

// ============================================
// PART 5: LOADING AND ERROR BOUNDARIES
// ============================================
// app/blog/loading.tsx
export default function Loading() {
  return <p>Loading...</p>;
}

// app/blog/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <p>{error.message}</p>
      <button onClick={reset}>Retry</button>
    </div>
  );
}

// ============================================
// PART 6: SERVER COMPONENT FETCHING DATA
// ============================================
// app/posts/page.tsx
export default async function PostsPage() {
  const res = await fetch('https://api.example.com/posts');
  const posts = await res.json();

  return (
    <ul>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ul>
  );
}

// ============================================
// PART 7: CLIENT COMPONENT BOUNDARY
// ============================================
// app/components/like-button.tsx
'use client';

import { useState } from 'react';

export function LikeButton() {
  const [liked, setLiked] = useState(false);
  return (
    <button onClick={() => setLiked(!liked)}>
      {liked ? 'Liked' : 'Like'}
    </button>
  );
}

// ============================================
// PART 8: COMPOSING SERVER AND CLIENT
// ============================================
// app/posts/[id]/page.tsx
import { LikeButton } from '@/app/components/like-button';

export default async function PostPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const res = await fetch(`https://api.example.com/posts/${id}`);
  const post = await res.json();

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

// ============================================
// PART 9: ROUTE GROUPS AND PRIVATE FOLDERS
// ============================================
// app/(marketing)/about/page.tsx → /about (group omitted)
// app/blog/_components/Post.tsx → not routable

// ============================================
// PART 10: THE COMPLETE FLOW
// ============================================
// 1. create-next-app scaffolds app/ with layout + page.
// 2. Folders define routes; page.tsx makes them public.
// 3. layout.tsx wraps and persists across navigation.
// 4. loading.tsx and error.tsx provide boundaries.
// 5. Server Components are default; fetch async directly.
// 6. 'use client' marks the interactive boundary.
// 7. Build enforces caching rules; Suspense or 'use cache'.

Quick Reference

Routing Files

FilePurposeRequired
layout.tsxShared UI, persists across navigationRoot layout only
page.tsxPublic route UIFor every route
loading.tsxAutomatic Suspense fallbackOptional
error.tsxSegment error boundary (Client Component)Optional
not-found.tsx404 UIOptional
route.tsAPI endpointOptional

Dynamic Segments

PatternURLParams
[slug]/blog/a{ slug: 'a' }
[...slug]/shop/a/b{ slug: ['a', 'b'] }
[[...slug]]/shop and /shop/a{ slug: undefined } or array

Server vs Client

AspectServer ComponentClient Component
DirectiveNone (default)'use client'
HooksNoYes
Browser APIsNoYes
Async componentYesNo
Ships JSNoYes

Setup Commands

CommandPurpose
npx create-next-app@latestScaffold with prompts
--typescript --eslint --tailwindNon-interactive flags
npm run devDevelopment server
npm run buildProduction build with prerender checks

Best Practices

✅ Do This:

// Keep pages as Server Components by default.                // ✅
export default async function Page() { const data = await fetch(...); }

// Push 'use client' to leaf components.                      // ✅
import { LikeButton } from './like-button';

// Await params in dynamic routes (Next.js 15+).              // ✅
const { slug } = await params;

// Use loading.tsx for route-level Suspense.                  // ✅
// app/blog/loading.tsx

// Wrap request-time data in Suspense.                        // ✅
<Suspense fallback={<Skeleton />}><Dynamic /></Suspense>

❌ Don’t Do This:

// Mark the entire page as 'use client' for one button.       // ❌
'use client'; // Ships the whole page to the browser.

// Use useState or useEffect in a Server Component.           // ❌
// Hooks require 'use client'.

// Assume params is a plain object (Next.js 15+).             // ❌
const { slug } = params; // Must await.

// Access cookies() or headers() without Suspense.            // ❌
// Build fails: blocking-prerender-dynamic.

// Fetch data with useEffect in the App Router.               // ❌
// Use async Server Component instead.

Common Pitfalls

PitfallWhy It HappensFix
Build fails on dynamic dataUncached fetch outside SuspenseWrap in <Suspense> or use 'use cache'
params undefinedNot awaited (Next.js 15+)const { slug } = await params
Hooks error in pagePage is a Server ComponentMove hooks to a 'use client' leaf
Layout re-rendersUsed template.tsx instead of layout.tsxUse layout.tsx for persistence
Route not accessibleNo page.tsx in folderAdd page.tsx

Real-World Examples

1. Scaffold Command

npx create-next-app@latest my-app --typescript --eslint --tailwind --app

2. Root Layout

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <html><body>{children}</body></html>;
}

3. Static Page

// app/about/page.tsx → /about
export default function About() { return <h1>About</h1>; }

4. Dynamic Route

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

5. Loading Boundary

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

6. Error Boundary

'use client';
export default function Error({ error, reset }: { error: Error; reset: () => void }) {
  return <button onClick={reset}>{error.message}</button>;
}

7. Client Component

'use client';
import { useState } from 'react';
export function Counter() { const [n, setN] = useState(0); return <button onClick={() => setN(n + 1)}>{n}</button>; }

8. Async Server Fetch

export default async function Page() {
  const res = await fetch('https://api.example.com/data');
  const data = await res.json();
  return <pre>{JSON.stringify(data)}</pre>;
}

9. Route Group

// app/(marketing)/about/page.tsx → /about
// Group name omitted from URL.

10. Suspense for Dynamic Data

<Suspense fallback={<p>Loading...</p>}><DynamicComponent /></Suspense>

Visual: App Router Structure

┌──────────────────────────────────────────────┐
│  app/                                        │
│  ├── layout.tsx        → Root layout (required)│
│  ├── page.tsx          → /                   │
│  ├── blog/                                   │
│  │   ├── layout.tsx    → wraps /blog/*       │
│  │   ├── page.tsx      → /blog               │
│  │   ├── loading.tsx   → Suspense fallback   │
│  │   ├── error.tsx     → Error boundary      │
│  │   └── [slug]/                             │
│  │       └── page.tsx  → /blog/:slug         │
│  └── (marketing)/                            │
│      └── about/                              │
│          └── page.tsx  → /about (group hidden)│
│                                              │
│  Folders = URL segments.                     │
│  page.tsx makes a route public.              │
│  layout.tsx wraps and persists.              │
│                                              │
└──────────────────────────────────────────────┘

Server vs Client Boundary

┌──────────────────────────────────────────────┐
│  SERVER (default)        CLIENT ('use client')│
│                                              │
│  No JS shipped           Ships JS bundle     │
│  async/await             useState, useEffect │
│  DB, filesystem          window, localStorage│
│  No hooks                Event handlers      │
│                                              │
│  Page (Server) ──► LikeButton (Client)       │
│       │                │                     │
│       │ renders on     │ hydrates on         │
│       │ server         │ client              │
│       ▼                ▼                     │
│  HTML ──────────► Interactive                │
│                                              │
│  Keep 'use client' at the leaves.            │
│                                              │
└──────────────────────────────────────────────┘

Rendering Pipeline

┌──────────────────────────────────────────────┐
│  FIRST LOAD                                  │
│                                              │
│  Server Components → RSC Payload             │
│       │                                      │
│       ▼                                      │
│  RSC Payload + Client Components → HTML      │
│       │                                      │
│       ▼                                      │
│  Browser: HTML preview (non-interactive)     │
│       │                                      │
│       ▼                                      │
│  JS loads → hydrate Client Components        │
│       │                                      │
│       ▼                                      │
│  App is interactive                          │
│                                              │
│  SUBSEQUENT NAVIGATION                       │
│  RSC Payload prefetched and cached.          │
│  Client Components render on client only.    │
│                                              │
└──────────────────────────────────────────────┘

Build-Time Enforcement

┌──────────────────────────────────────────────┐
│  next build                                  │
│       │                                      │
│       ▼                                      │
│  For each route:                             │
│    Prerender static shell.                   │
│       │                                      │
│       ├── Request-time data outside Suspense?│
│       │         │                            │
│       │         ▼                            │
│       │   BUILD FAILS                        │
│       │   "blocking-prerender-dynamic"       │
│       │                                      │
│       └── Suspense or 'use cache'?           │
│                 │                            │
│                 ▼                            │
│           Prerender succeeds.                │
│           Dynamic parts stream.              │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Scaffoldingnpx create-next-app@latest
RoutingFolders under app/ with page.tsx
Root layoutapp/layout.tsx, renders <html> and <body>
Nested layoutlayout.tsx in a segment, persists across navigation
Loading UIloading.tsx, automatic Suspense boundary
Error UIerror.tsx, must be Client Component
Default componentServer Component, no JS shipped
Interactive component'use client' directive at top of file
Dynamic route[slug]/page.tsx, params is a Promise
Build constraintRequest-time data requires Suspense or 'use cache'

Key takeaways:

  • The App Router is file-system routing with Server Components as the default. A folder becomes a route when it contains a page.tsx. A layout.tsx wraps the route and persists across navigation. The setup is the convention, not a configuration file .
  • The root layout is the only required file. It must render <html> and <body>, and it receives the route as children. Every other file is optional .
  • Server Components are the default; 'use client' is the boundary. Server Components ship zero JavaScript, can be async, and cannot use hooks or browser APIs. The 'use client' directive marks the module graph where the client bundle begins .
  • Push the client boundary to the leaves. A page that is mostly static with one interactive component should keep the page as a Server Component and import the interactive component as a Client Component .
  • Data fetching is direct async/await. There is no getStaticProps or getServerSideProps. A Server Component fetches data directly, and the build step enforces the caching rules .
  • The build fails on request-time data outside Suspense. If a route accesses uncached data, cookies(), headers(), or params outside a <Suspense> boundary, next build reports blocking-prerender-dynamic and explains the three fixes: stream with Suspense, cache with 'use cache', or opt into blocking with export const instant = false .
  • params and searchParams are Promises in Next.js 15+. They must be awaited in Server Components. In Client Components, use the use() API or the useParams hook .

Remember: The Next.js App Router setup is a set of file conventions, not a configuration file. create-next-app scaffolds the structure, layout.tsx provides the persistent shell, page.tsx makes a route public, and loading.tsx and error.tsx provide the boundaries for the cases where data is still arriving or something failed. The default component is a Server Component that ships no JavaScript and can fetch data directly. The 'use client' directive is the setup decision that determines what ships to the browser, and it should be placed as deep in the tree as the interactivity requires. The build enforces the caching model: request-time data must be streamed, cached, or explicitly marked as blocking. The setup is complete when the conventions are understood.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!