Skip to main content

Command Palette

Search for a command to run...

Modern Next.js: The Complete Guide to App Router, Server Components, and Full-Stack Architecture

Updated
20 min readView as Markdown
Modern Next.js: The Complete Guide to App Router, Server Components, and Full-Stack Architecture

Modern Next.js: The Complete Guide to App Router, Server Components, and Full-Stack Architecture

TL;DR: Modern Next.js is no longer just a React framework with SSR — it's a complete full-stack platform. This guide covers the App Router, Server Components, API Routes, and Server Actions from first principles, explaining why each feature exists and how they fit together in production SaaS and dashboard applications.

Introduction: Can a React App Have Both Frontend and Backend?

The answer, as of Next.js 13+, is an unqualified yes — and not in the duct-taped, "we'll throw an Express server next to our Webpack config" way that used to define full-stack JavaScript. Modern Next.js lets you write database queries, authentication logic, form mutations, and UI components inside the same project, with clear architectural boundaries and framework-enforced conventions.

This matters enormously. The traditional separation between a React SPA and a Node.js API server introduced operational overhead (two deployments, two CI pipelines, CORS configuration, shared type maintenance) and architectural complexity that slowed down small teams and created coordination problems in large ones.

This guide is for developers who already understand React fundamentals and want to build serious applications with Next.js. We assume familiarity with components, props, async/await, and basic HTTP concepts. We will not spend time on installation basics — instead, we will build a mental model for the entire modern Next.js architecture, explain the trade-offs honestly, and walk through real code patterns used in production applications.


Part 1: The Evolution of Next.js

Next.js started in 2016 as a thin wrapper around React that made server-side rendering (SSR) practical. The pages/ directory model was elegant: every file became a route, getServerSideProps fetched data on the server, and getStaticProps enabled static generation. For several years, this was the dominant React framework pattern.

But the pages/ model had structural limitations that became painful at scale:

  • Layouts were not a first-class concept. Wrapping pages in shared UI required a manual _app.tsx convention that didn't support nested layouts cleanly.
  • Data fetching was page-level only. You couldn't colocate a deeply nested component with its own server-side data fetch — all data had to flow down from getServerSideProps.
  • The entire page was either server-rendered or client-rendered. There was no granular control at the component level.
  • API Routes and page routes lived in entirely separate mental models. Backend logic in pages/api/ felt bolted on rather than architecturally integrated.

The App Router, introduced in Next.js 13 and stabilized in Next.js 14, is a fundamental redesign built on React's Server Components specification. It addresses every one of these limitations and represents the modern way to build Next.js applications.


Part 2: Understanding the App Router

Folder-Based Routing Architecture

The App Router lives inside the app/ directory. Every folder represents a URL segment. A page.tsx file inside a folder makes that route publicly accessible.

app/
├── page.tsx              → /
├── about/
│   └── page.tsx          → /about
├── dashboard/
│   ├── page.tsx          → /dashboard
│   ├── analytics/
│   │   └── page.tsx      → /dashboard/analytics
│   └── settings/
│       └── page.tsx      → /dashboard/settings
└── blog/
    ├── page.tsx          → /blog
    └── [slug]/
        └── page.tsx      → /blog/:slug

The [slug] folder syntax creates dynamic routes. The folder name in brackets becomes a parameter available in the component's props.

// app/blog/[slug]/page.tsx
interface BlogPostPageProps {
  params: { slug: string };
}

export default async function BlogPostPage({ params }: BlogPostPageProps) {
  const post = await fetchPostBySlug(params.slug);

  if (!post) {
    return <div>Post not found</div>;
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.publishedAt}</p>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Note: In the App Router, page.tsx files are Server Components by default. The async/await in the component above is real — you can await database calls directly in the component body.

Route Groups and Organizational Folders

Route groups — folders wrapped in parentheses like (marketing) — let you organize files without affecting the URL structure. This is essential for large applications.

app/
├── (marketing)/
│   ├── page.tsx           → /
│   ├── about/
│   │   └── page.tsx       → /about
│   └── pricing/
│       └── page.tsx       → /pricing
└── (dashboard)/
    ├── layout.tsx         → Shared dashboard layout
    ├── overview/
    │   └── page.tsx       → /overview
    └── users/
        └── page.tsx       → /users

The (marketing) and (dashboard) segments don't appear in URLs. They exist purely to allow different layout.tsx files for each section — a critical pattern for SaaS applications where the public marketing site and the authenticated dashboard need completely different shells.

Next.js App Router folder hierarchy showing route groups, layouts, and page files mapped to URL paths


Part 3: Layouts — The Most Underrated Feature

Layouts are the architectural backbone of the App Router. A layout.tsx file wraps all routes nested beneath it and persists across navigation — it does not unmount and remount when the user moves between pages inside the same layout scope.

The Root Layout

Every App Router application requires a root layout at app/layout.tsx. This is where your <html> and <body> tags live.

// app/layout.tsx
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 Dashboard', default: 'Acme Dashboard' },
  description: 'The operations dashboard for Acme Corp.',
};

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

Nested Layouts

The power becomes apparent with nested layouts. A dashboard section can have its own layout that includes a sidebar and top navigation, without duplicating that code across every dashboard page.

// app/(dashboard)/layout.tsx
import { Sidebar } from '@/components/sidebar';
import { TopNav } from '@/components/top-nav';
import { getCurrentUser } from '@/lib/auth';
import { redirect } from 'next/navigation';

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const user = await getCurrentUser();

  if (!user) {
    redirect('/login');
  }

  return (
    <div className="flex h-screen">
      <Sidebar user={user} />
      <div className="flex flex-col flex-1 overflow-hidden">
        <TopNav user={user} />
        <main className="flex-1 overflow-y-auto p-6">
          {children}
        </main>
      </div>
    </div>
  );
}

This layout does two things: it enforces authentication (unauthenticated users are redirected to /login), and it wraps all dashboard routes in a consistent shell. Every page under app/(dashboard)/ automatically inherits this wrapper.

flowchart TD
    Root["app/layout.tsx\n(html, body)"] --> Marketing["(marketing)/layout.tsx\nPublic Nav + Footer"]
    Root --> Dashboard["(dashboard)/layout.tsx\nSidebar + TopNav + Auth Check"]
    Marketing --> Home["page.tsx → /"]
    Marketing --> About["about/page.tsx → /about"]
    Dashboard --> Overview["overview/page.tsx → /overview"]
    Dashboard --> Users["users/page.tsx → /users"]
    Dashboard --> Analytics["analytics/page.tsx → /analytics"]

Part 4: Server Components — The Paradigm Shift

Server Components are the single most significant architectural change in modern React. Understanding them deeply is non-negotiable for writing good Next.js applications.

What Server Components Are

In traditional React, every component ran in the browser. Even with SSR, the JavaScript for every component was shipped to the client so React could hydrate the page. Server Components break this assumption: some components render only on the server, their output (serialized React elements — not HTML) is streamed to the client, and their JavaScript is never included in the client bundle.

This has radical implications:

  • You can import server-only libraries (database clients, file system modules, secret-using SDKs) directly in a component without exposing them to the browser.
  • The component can be async and directly await data without useEffect or client-side fetching.
  • The JavaScript bundle shrinks because server-only code is never sent to the browser.
// app/(dashboard)/users/page.tsx
// This is a Server Component. No 'use client' directive.
import { db } from '@/lib/database';
import { UserTable } from './user-table';

export default async function UsersPage() {
  // Direct database query — this runs on the server.
  // The database client code is NEVER sent to the browser.
  const users = await db.user.findMany({
    select: {
      id: true,
      name: true,
      email: true,
      role: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
    take: 100,
  });

  return (
    <div>
      <h1 className="text-2xl font-bold mb-6">Users</h1>
      <UserTable users={users} />
    </div>
  );
}

The db import above might be Prisma, Drizzle, or a raw pg client. It requires database credentials. None of that reaches the browser. The component runs, produces serialized React elements, and the client receives only the rendered output.

Architecture diagram comparing Server Components and Client Components showing execution environments and data flow

Client Components

When you need interactivity — event handlers, state, browser APIs, lifecycle effects — you opt into a Client Component with the 'use client' directive.

// app/(dashboard)/users/user-table.tsx
'use client';

import { useState } from 'react';
import { User } from '@/types';

interface UserTableProps {
  users: User[];
}

export function UserTable({ users }: UserTableProps) {
  const [search, setSearch] = useState('');

  const filteredUsers = users.filter(
    (user) =>
      user.name.toLowerCase().includes(search.toLowerCase()) ||
      user.email.toLowerCase().includes(search.toLowerCase())
  );

  return (
    <div>
      <input
        type="text"
        placeholder="Search users..."
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        className="mb-4 w-full border rounded-lg px-3 py-2"
      />
      <table className="w-full">
        <thead>
          <tr>
            <th>Name</th>
            <th>Email</th>
            <th>Role</th>
            <th>Joined</th>
          </tr>
        </thead>
        <tbody>
          {filteredUsers.map((user) => (
            <tr key={user.id}>
              <td>{user.name}</td>
              <td>{user.email}</td>
              <td>{user.role}</td>
              <td>{new Date(user.createdAt).toLocaleDateString()}</td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Notice the composition pattern: the Server Component (UsersPage) handles data fetching. The Client Component (UserTable) handles interactivity. The Server Component passes serialized data (a plain array) to the Client Component as props.

Warning: You cannot import a Client Component into a Server Component and then pass React components (JSX) as props from the client to the server. Data flows down from server to client, not up. Server Components can be passed as children to Client Components — this is the key composition pattern.

The Composition Rule

// ✅ Correct: Server Component as children of a Client Component
// app/(dashboard)/layout.tsx
import { ThemeProvider } from '@/components/theme-provider'; // 'use client'

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <ThemeProvider>
      {children}  {/* children can be Server Components */}
    </ThemeProvider>
  );
}

Part 5: API Routes

API Routes let you create HTTP endpoints inside your Next.js application. In the App Router, they live in route.ts files.

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/database';
import { getCurrentUser } from '@/lib/auth';
import { z } from 'zod';

const createUserSchema = z.object({
  name: z.string().min(2).max(100),
  email: z.string().email(),
  role: z.enum(['admin', 'member', 'viewer']),
});

export async function GET(request: NextRequest) {
  const currentUser = await getCurrentUser();
  if (!currentUser || currentUser.role !== 'admin') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const users = await db.user.findMany({
    select: { id: true, name: true, email: true, role: true },
  });

  return NextResponse.json({ users });
}

export async function POST(request: NextRequest) {
  const currentUser = await getCurrentUser();
  if (!currentUser || currentUser.role !== 'admin') {
    return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
  }

  const body = await request.json();
  const parsed = createUserSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json(
      { error: 'Invalid input', details: parsed.error.flatten() },
      { status: 400 }
    );
  }

  const existingUser = await db.user.findUnique({
    where: { email: parsed.data.email },
  });

  if (existingUser) {
    return NextResponse.json(
      { error: 'Email already in use' },
      { status: 409 }
    );
  }

  const user = await db.user.create({ data: parsed.data });
  return NextResponse.json({ user }, { status: 201 });
}

API Routes are ideal when you need a public HTTP interface — for mobile apps, external services, webhooks (Stripe, GitHub), or third-party integrations that need to call your backend over HTTP.


Part 6: Server Actions — The Modern Mutation Model

Server Actions are asynchronous functions that run on the server and can be called directly from Client Components. They eliminate the need to write an API Route for many internal mutations.

// app/(dashboard)/users/actions.ts
'use server';

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

const inviteUserSchema = z.object({
  email: z.string().email(),
  role: z.enum(['admin', 'member', 'viewer']),
});

export async function inviteUser(formData: FormData) {
  const currentUser = await getCurrentUser();
  if (!currentUser || currentUser.role !== 'admin') {
    throw new Error('Unauthorized');
  }

  const parsed = inviteUserSchema.safeParse({
    email: formData.get('email'),
    role: formData.get('role'),
  });

  if (!parsed.success) {
    return { error: parsed.error.flatten().fieldErrors };
  }

  await db.invitation.create({
    data: {
      email: parsed.data.email,
      role: parsed.data.role,
      invitedById: currentUser.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  });

  await sendInvitationEmail(parsed.data.email);

  // Revalidate the users list so it reflects the new pending invitation
  revalidatePath('/users');

  return { success: true };
}

Using it from a Client Component:

// app/(dashboard)/users/invite-form.tsx
'use client';

import { useFormState, useFormStatus } from 'react-dom';
import { inviteUser } from './actions';

function SubmitButton() {
  const { pending } = useFormStatus();
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Sending invite...' : 'Send Invite'}
    </button>
  );
}

export function InviteUserForm() {
  const [state, action] = useFormState(inviteUser, null);

  return (
    <form action={action} className="space-y-4">
      <div>
        <label htmlFor="email">Email address</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          className="border rounded px-3 py-2 w-full"
        />
        {state?.error?.email && (
          <p className="text-red-500 text-sm">{state.error.email[0]}</p>
        )}
      </div>
      <div>
        <label htmlFor="role">Role</label>
        <select id="role" name="role" className="border rounded px-3 py-2 w-full">
          <option value="member">Member</option>
          <option value="admin">Admin</option>
          <option value="viewer">Viewer</option>
        </select>
      </div>
      <SubmitButton />
    </form>
  );
}

The form's action prop directly receives the Server Action. When submitted, Next.js serializes the form data, sends it to the server, the action runs, and revalidatePath triggers a cache invalidation that causes the users list to refresh. No API endpoint, no fetch call, no manual state management for loading states (the useFormStatus hook handles that automatically).


Part 7: API Routes vs. Server Actions

This is one of the most common architectural questions in modern Next.js. The answer is not "use one and ignore the other" — they serve genuinely different purposes.

Concern API Routes Server Actions
Primary use case External consumers, webhooks, mobile apps Internal form mutations, in-app data updates
Called via HTTP (fetch, curl, SDKs) Direct function call from Client Components
Request format JSON, FormData, multipart FormData (native), JSON via bind
Authentication Manual in each handler Shared auth utilities, called per action
Caching Manual via Response headers revalidatePath / revalidateTag
Error handling HTTP status codes Return values or thrown errors
Boilerplate Higher Lower for internal mutations
Discoverability Explicit HTTP endpoint (documentable) Internal function — harder to expose externally
When to use Stripe webhooks, mobile app backend, public API Invite user, update profile, delete record

Tip: A good rule of thumb: if the caller is outside your Next.js application (a mobile app, a Zapier integration, a curl script), use an API Route. If the caller is a component inside your application, use a Server Action.


Part 8: Authentication Architecture

Authentication in the App Router uses a combination of middleware, layouts, and Server Actions. Here is the full flow:

flowchart LR
    Request(["HTTP Request"]) --> Middleware["middleware.ts\nCheck session cookie"]
    Middleware -->|"No session"| Redirect["Redirect to /login"]
    Middleware -->|"Valid session"| Layout["(dashboard)/layout.tsx\nVerify + fetch user"]
    Layout -->|"Unauthorized"| Redirect
    Layout -->|"Authorized"| Page["page.tsx\nRender with user context"]
// middleware.ts (at project root)
import { NextRequest, NextResponse } from 'next/server';
import { verifySessionToken } from '@/lib/auth';

const PUBLIC_ROUTES = ['/', '/login', '/signup', '/about', '/pricing'];
const AUTH_ROUTES = ['/login', '/signup'];

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const sessionToken = request.cookies.get('session')?.value;

  const isPublicRoute = PUBLIC_ROUTES.some(
    (route) => pathname === route || pathname.startsWith('/api/public/')
  );

  const session = sessionToken
    ? await verifySessionToken(sessionToken)
    : null;

  // Redirect authenticated users away from auth pages
  if (session && AUTH_ROUTES.includes(pathname)) {
    return NextResponse.redirect(new URL('/overview', request.url));
  }

  // Redirect unauthenticated users away from protected routes
  if (!session && !isPublicRoute) {
    const loginUrl = new URL('/login', request.url);
    loginUrl.searchParams.set('callbackUrl', pathname);
    return NextResponse.redirect(loginUrl);
  }

  return NextResponse.next();
}

export const config = {
  // Run on all routes except static assets and API routes that handle their own auth
  matcher: ['/((?!_next/static|_next/image|favicon.ico|api/webhooks).*)'],
};

Part 9: Data Fetching Patterns

Server-Side Fetching with Caching

Next.js extends the native fetch API with caching semantics.

// lib/api.ts
export async function fetchProductCatalog() {
  const res = await fetch('https://api.acme.com/products', {
    // Cache this response and revalidate at most every 60 seconds
    next: { revalidate: 60, tags: ['products'] },
  });

  if (!res.ok) throw new Error('Failed to fetch product catalog');
  return res.json();
}

export async function fetchUserOrders(userId: string) {
  const res = await fetch(`https://api.acme.com/users/${userId}/orders`, {
    // No cache — always fresh, user-specific data
    cache: 'no-store',
  });

  if (!res.ok) throw new Error('Failed to fetch orders');
  return res.json();
}

Parallel Data Fetching

Avoid sequential awaits — fetch in parallel with Promise.all:

// app/(dashboard)/overview/page.tsx
import { fetchRevenueMetrics, fetchActiveUsers, fetchRecentActivity } from '@/lib/data';

export default async function OverviewPage() {
  // All three fetches start simultaneously
  const [metrics, activeUsers, recentActivity] = await Promise.all([
    fetchRevenueMetrics(),
    fetchActiveUsers(),
    fetchRecentActivity(),
  ]);

  return (
    <div className="grid grid-cols-3 gap-6">
      <MetricsCard data={metrics} />
      <ActiveUsersCard data={activeUsers} />
      <RecentActivityFeed data={recentActivity} />
    </div>
  );
}

Streaming with Suspense

For slow data sources, use React Suspense to stream in content progressively:

// app/(dashboard)/overview/page.tsx
import { Suspense } from 'react';
import { RevenueChart } from './revenue-chart';
import { RevenueChartSkeleton } from './skeletons';

export default function OverviewPage() {
  return (
    <div>
      <h1>Overview</h1>
      {/* The Suspense boundary streams in the chart when ready */}
      <Suspense fallback={<RevenueChartSkeleton />}>
        <RevenueChart />
      </Suspense>
    </div>
  );
}

// app/(dashboard)/overview/revenue-chart.tsx
async function RevenueChart() {
  // This slow query doesn't block the rest of the page
  const data = await fetchLast12MonthsRevenue(); // ~800ms
  return <Chart data={data} />;
}

Part 10: Trade-offs and Pitfalls

Area Trade-off / Risk
Server Components Cannot use browser APIs, useState, or event handlers. Over-using them means re-fetching data on every navigation even when client-side caching would suffice.
Server Actions Not a replacement for all API Routes. External consumers cannot call them. Error handling surface area is different from REST — requires careful design.
Layouts persisting Persistent layouts are great for UX but mean layout-level state can become stale. You need explicit revalidation strategies.
App Router caching Next.js has a multi-layer cache (Router Cache, Full Route Cache, Data Cache) that can be confusing. Stale data bugs are common for developers new to the model.
Middleware overhead Middleware runs on every request. Expensive operations (full JWT verification with database lookup) will hurt TTFB. Keep middleware lightweight — verify signature only, do full lookup in the layout.
Bundle size The 'use client' boundary creates a new client bundle entry. Inadvertently marking large subtrees as client components negates the bundle size benefits of Server Components.

Warning: The App Router's caching behavior changed significantly across Next.js 14 and 15. Always check the version-specific docs. In Next.js 15, GET route handlers and page components are no longer cached by default — a significant behavioral change from 14.


Part 11: Large-Scale Application Structure

For a production SaaS dashboard, a practical folder structure looks like this:

acme-dashboard/
├── app/
│   ├── (marketing)/           # Public site
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── pricing/
│   ├── (auth)/                # Login / signup flows
│   │   ├── login/
│   │   └── signup/
│   ├── (dashboard)/           # Authenticated app
│   │   ├── layout.tsx         # Auth check + shell
│   │   ├── overview/
│   │   ├── users/
│   │   │   ├── page.tsx
│   │   │   ├── actions.ts     # Server Actions
│   │   │   ├── user-table.tsx
│   │   │   └── invite-form.tsx
│   │   └── settings/
│   └── api/
│       ├── webhooks/
│       │   └── stripe/
│       └── v1/                # Public API
├── components/                # Shared UI components
│   ├── ui/                    # Primitives (Button, Input, Modal)
│   └── layout/               # Nav, Sidebar, etc.
├── lib/
│   ├── auth.ts
│   ├── database.ts
│   └── validations.ts
├── types/
└── middleware.ts

The key principle: colocate feature code. A users/ directory contains the page, the Server Actions, and the Client Components for that feature. You shouldn't need to hunt across the project to understand a single feature's full implementation.

Large-scale Next.js SaaS application folder structure showing feature-based organization with routes, components, and API layers


Best Practices

  1. Default to Server Components. Start every new component as a Server Component. Add 'use client' only when you actually need interactivity, state, or browser APIs.

  2. Push 'use client' to the leaves. Keep your Client Components small and focused. A large page-level Client Component negates Server Component benefits. Instead, have a Server Component page that renders Client Components only where interaction is needed.

  3. Use Server Actions for in-app mutations. Don't reflexively create an API Route for every form. Server Actions reduce boilerplate significantly and integrate naturally with React's form model.

  4. Keep API Routes for external interfaces. Webhooks, mobile app backends, and public APIs belong in app/api/. These benefit from the explicitness of HTTP endpoints.

  5. Validate inputs in both Server Actions and API Routes. Never trust incoming data. Use Zod or a similar validation library at the entry point of every server-side handler.

  6. Use revalidatePath and revalidateTag intentionally. After a Server Action mutates data, explicitly invalidate the relevant cache entries. Forgetting this is the most common cause of stale UI.

  7. Keep middleware lightweight. Verify JWT signatures in middleware, but defer database lookups to layouts or page components. Middleware runs on every request — even for static assets if your matcher is too broad.

  8. Use Suspense boundaries strategically. Wrap slow data-fetching components in <Suspense> to enable streaming. This improves Time to First Byte and perceived performance significantly.


Conclusion

Modern Next.js represents a genuine architectural rethinking of full-stack web development. The App Router is not just a new API for routing — it's the foundation of a component model that lets you precisely control what runs on the server and what runs in the browser, with clear and enforceable boundaries.

The key mental models to internalize:

  • Layouts define persistent shells; route groups let you have multiple independent shells in one project.
  • Server Components are the default; they fetch data directly and never ship their implementation to the browser.
  • Client Components are opt-in; use them only at interaction boundaries.
  • API Routes are for external HTTP consumers; Server Actions are for internal mutations.
  • Middleware enforces authentication at the edge, before any rendering happens.

If you are building a new full-stack application today, the App Router with Server Components and Server Actions is the architecture to start with. It is not perfect — the caching model has complexity, the Server/Client boundary requires discipline, and the documentation for edge cases can be sparse. But the fundamentals are sound, and the performance and developer experience benefits compound as the application grows.

The practical next step: take an existing page in your application, identify which parts require interactivity, push those into small Client Components, and let everything else become Server Components. Measure the bundle size reduction. Then revisit one API Route used only internally and replace it with a Server Action. The shift will become intuitive quickly.


Further Reading