Next.js App Router: Server Components, Server Actions, Streaming and Parallel Routes

Key takeaways

The App Router maps folders to URL segments and special files (layout, page, loading, error) to UI states. Components render on the server by default; "use client" marks where the browser bundle starts. This guide covers that boundary, Server Actions, streaming, and parallel and intercepting routes, with the errors each one produces when misused.

What this post covers

The App Router (the app/ directory, stable since Next.js 13.4) replaces pages/, _app.tsx, getServerSideProps and getStaticProps with a different model: React Server Components render on the server by default, layouts nest by folder, and loading and error states are files rather than component state. This post goes through the pieces you touch when building a feature (routing conventions, the server/client boundary, Server Actions, streaming, parallel and intercepting routes) and the mistakes each one invites.

Caching and rendering modes (static, dynamic, ISR, revalidate) are a large topic on their own and are covered in Next.js App Router: SSR vs SSG vs ISR. Examples here use Next.js 15 conventions, where params is a Promise and fetch is not cached by default.


File-based routing

app/
├── layout.tsx          # Root layout: <html> and <body>, wraps everything
├── page.tsx            # /
├── about/
│   └── page.tsx        # /about
├── blog/
│   ├── layout.tsx      # Wraps /blog and /blog/*
│   ├── page.tsx        # /blog
│   └── [slug]/
│       ├── page.tsx    # /blog/:slug
│       ├── loading.tsx # Shown while page.tsx is loading
│       └── error.tsx   # Shown if page.tsx throws
└── (marketing)/        # Route group: organises files, not part of the URL
    └── pricing/
        └── page.tsx    # /pricing

A folder becomes a URL segment only when it contains a page.tsx (or a route.ts for an API endpoint). Other files in the folder, such as components or utilities, are not routable, so colocating them is safe. Parenthesised folders like (marketing) are route groups: they let you give a set of pages its own layout without adding a segment to the URL.

Layouts are the biggest behavioural change from the Pages Router. A layout.tsx wraps every page below it and stays mounted when you navigate between those pages. State in a client component inside the layout (an open sidebar, a search box) survives navigation, and the layout’s server code does not re-run on each navigation. That is usually what you want, but it surprises people who put per-page logic, such as reading the current path to highlight a nav item, into a server layout. It will not update; use a client component with usePathname() for that.


Server Components by default

// app/posts/page.tsx
type Post = { id: number; title: string };

async function getPosts(): Promise<Post[]> {
  const res = await fetch('https://api.example.com/posts');
  if (!res.ok) throw new Error(`Failed to load posts: ${res.status}`);
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <a href={`/posts/${post.id}`}>{post.title}</a>
          </li>
        ))}
      </ul>
    </main>
  );
}

A Server Component can be async, can await data directly, and can use server-only resources (database clients, secrets in environment variables, the file system) because its code never ships to the browser. Only the rendered result is sent, in a serialised format the client uses to build the React tree. Large dependencies used only on the server, such as a Markdown parser or a date library used for formatting, add nothing to the client bundle.

The cost is that Server Components cannot be interactive. They render once per request (or once at build time, if the route is static) and have no state, no effects, and no event handlers. Using a hook in one fails the build with an error along the lines of “You’re importing a component that needs useState. This React Hook only works in a Client Component.”

Checking res.ok matters: fetch does not throw on HTTP 404 or 500, and calling .json() on an HTML error page produces a confusing SyntaxError: Unexpected token '<' rather than a useful message.


Client Components and the boundary

// app/components/Counter.tsx
'use client';

import { useState } from 'react';

export function Counter({ initial }: { initial: number }) {
  const [count, setCount] = useState(initial);
  return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
// app/posts/page.tsx (Server Component)
import { Counter } from '../components/Counter';

export default async function PostsPage() {
  const posts = await getPosts();
  return (
    <main>
      <Counter initial={posts.length} />
      <PostList posts={posts} />
    </main>
  );
}

'use client' does not mean “render only in the browser”. Client Components are still rendered to HTML on the server for the first load and then hydrated. What the directive marks is a boundary: that file and everything it imports are included in the client bundle.

Three consequences follow:

  1. Put the directive as low in the tree as you can. Marking a whole page 'use client' because one button needs onClick pulls the page’s imports into the bundle and loses direct data access. Extract the button instead.
  2. Props crossing the boundary must be serialisable: strings, numbers, plain objects, arrays, Dates, and a few others. Passing a regular function fails with “Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with “use server”.” Class instances lose their methods.
  3. Server Components can be passed as children to a Client Component. A client-side <Tabs> wrapper can receive server-rendered panels as children without turning them into client code, because the server renders them first and passes the result.

To guarantee that a module with secrets never ends up in the client bundle by accident, import the server-only package at the top of it; any attempt to import it from a Client Component then fails at build time. Environment variables without the NEXT_PUBLIC_ prefix are not inlined into client code, so a client component that reads process.env.DATABASE_URL just sees undefined.

The mistake I see most often in App Router codebases is 'use client' creeping upwards. One component needs useState, the directive goes on its parent to fix an error, then on the page, and within a few weeks most of the app is client-rendered and fetching data in useEffect, with none of the benefits the App Router was adopted for. When you hit the hooks error, the fix is almost always to split the component, not to move the directive up.


Loading and error UI (streaming)

// app/posts/loading.tsx
export default function Loading() {
  return <p>Loading posts...</p>;
}
// app/posts/error.tsx
'use client';

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

loading.tsx wraps the segment’s page in a React <Suspense> boundary with the file as the fallback. The server immediately streams the layout and the loading UI, then streams the page’s HTML when its data resolves. Users see the page frame instantly instead of a blank screen while the slowest query finishes.

For finer control, wrap slow parts in your own <Suspense> boundaries so fast sections render first:

import { Suspense } from 'react';

export default function Dashboard() {
  return (
    <>
      <Summary />                       {/* fast */}
      <Suspense fallback={<p>Loading chart...</p>}>
        <SlowChart />                   {/* async server component */}
      </Suspense>
    </>
  );
}

error.tsx must be a Client Component because it uses reset. It catches errors thrown while rendering the segment below it, but not errors in the layout.tsx of the same segment (that layout wraps the error boundary); to catch those, add an error.tsx in the parent segment, or global-error.tsx for the root layout. In production, messages of errors thrown in Server Components are replaced with a generic message and a digest so internals do not leak to users; match the digest against server logs.

A waterfall is the other streaming pitfall: const a = await getA(); const b = await getB(); runs the requests sequentially. When they are independent, start both first and await together with Promise.all, or split them into separate components under separate Suspense boundaries.


Server Actions

// app/posts/new/actions.ts
'use server';

import { redirect } from 'next/navigation';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function createPost(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  const title = String(formData.get('title') ?? '').trim();
  const content = String(formData.get('content') ?? '').trim();
  if (!title || !content) throw new Error('Title and content are required');

  await db.post.create({ data: { title, content, authorId: session.userId } });
  revalidatePath('/posts');
  redirect('/posts');
}
// app/posts/new/page.tsx
import { createPost } from './actions';
import { SubmitButton } from './SubmitButton';

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" placeholder="Title" required />
      <textarea name="content" placeholder="Content" required />
      <SubmitButton />
    </form>
  );
}
// app/posts/new/SubmitButton.tsx
'use client';

import { useFormStatus } from 'react-dom';

export function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create post'}
    </button>
  );
}

A Server Action is an async function marked 'use server' (either inline in a Server Component or, as here, in a module where every export becomes an action). Passing it to <form action> makes the form work even before JavaScript loads: without JS it is a normal POST, and with JS Next.js intercepts the submission and calls the action without a full page reload.

Treat every action as a public API endpoint. Next.js generates an ID for it and accepts POST requests to it; nothing stops someone from calling it with arbitrary form data, bypassing the required attributes and any client-side checks. The auth() call and the validation above are not optional. The same applies to the authorId: take it from the session, never from a hidden form field.

Two smaller details: useFormStatus only reports the status of the <form> it is rendered inside, so it must live in a child component, not in the component that renders the form. And redirect() works by throwing a special error, so do not call it inside a try block whose catch swallows everything. To show validation errors instead of throwing, have the action return a state object and use React’s useActionState in a Client Component.


Dynamic segments and metadata

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';

type Props = { params: Promise<{ slug: string }> };

async function getPost(slug: string) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    next: { revalidate: 60 },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error(`Failed to load post: ${res.status}`);
  return res.json();
}

export async function generateMetadata({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  return post ? { title: post.title, description: post.excerpt } : {};
}

export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);
  if (!post) notFound();
  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  );
}

In Next.js 15, params and searchParams are Promises and must be awaited. Code written for 13 and 14 (params.slug directly) still works for now but logs a warning, and the official codemod (npx @next/codemod@latest next-async-request-api .) rewrites it.

generateMetadata and the page both call getPost. fetch requests with the same URL and options are memoised for the duration of one render, so this does not hit the API twice; for a database query that does not go through fetch, wrap the function in React’s cache() to get the same deduplication. Distinguishing 404 from other failures matters: returning null for every non-OK response turns an API outage into “page not found”, which search engines treat very differently from a temporary error.


Parallel routes

app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│   ├── page.tsx
│   └── default.tsx
└── @notifications/
    ├── page.tsx
    └── default.tsx
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  notifications,
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <div className="grid">
      <section>{children}</section>
      <aside>{analytics}</aside>
      <aside>{notifications}</aside>
    </div>
  );
}

Folders starting with @ are slots. They do not appear in the URL; each one is passed to the parent layout as a prop with the same name. Each slot can have its own loading.tsx and error.tsx, so a slow analytics query streams in independently and a failing notifications service shows an error in its panel without taking down the dashboard.

The part that catches everyone is default.tsx. During client-side navigation, a slot that has no match for the new URL keeps showing what it showed before. On a hard load (refresh, or opening the URL in a new tab) there is no previous state, so Next.js renders the slot’s default.tsx, and if there is none, the whole route returns 404. Pages that work while clicking around and 404 on refresh almost always have a missing default.tsx.


Intercepting routes (modals)

app/
├── layout.tsx            # renders {children} and {modal}
├── @modal/
│   ├── default.tsx       # returns null when no modal is open
│   └── (.)photos/
│       └── [id]/
│           └── page.tsx  # modal version of /photos/:id
└── photos/
    ├── page.tsx          # gallery
    └── [id]/
        └── page.tsx      # full page version of /photos/:id
// app/@modal/(.)photos/[id]/page.tsx
import { Modal } from '@/components/Modal';

export default async function PhotoModal({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  return (
    <Modal>
      <img src={`/photos/${id}.jpg`} alt={`Photo ${id}`} />
    </Modal>
  );
}

An intercepting route renders a different UI for a URL when you navigate to it from inside the app. Clicking a photo in the gallery updates the URL to /photos/42 but shows the modal over the gallery; refreshing or sharing that URL shows the full /photos/[id]/page.tsx. The prefix works like relative paths on route segments: (.) matches the same level, (..) one level up, (...) the root. Slots and route groups do not count as segments, which is why app/@modal/(.)photos intercepts /photos.

Combining this with a parallel @modal slot is the standard pattern. The modal component closes by calling router.back(), which pops the intercepted URL and brings back the slot’s default.tsx. Forgetting default.tsx here produces the same refresh-404 as in the previous section.