The introduction of the App Router in Next.js represents the most consequential paradigm shift in the React ecosystem since the introduction of Hooks. Built natively on React Server Components (RSC), streaming architecture, and Suspense, the App Router replaces the legacy Pages Router model with a server-first mental model that redefines how applications fetch data, serialize state across network boundaries, and navigate dynamic routes.
Rather than shipping massive JavaScript bundles to the client to render empty shells that subsequently fetch data over multiple API round-trips, the App Router executes components on the server by default. It streams pre-rendered HTML and a lightweight virtual tree protocol directly to the browser, eliminating unnecessary client-side JavaScript while preserving rich client-side interactivity.
This guide provides an exhaustive, university-grade architectural analysis of the Next.js App Router and its routing subsystems. We dissect the React Flight serialization protocol, trace layout nesting and template lifecycles, formalize parallel and intercepting route state matrices, master the Server-to-Client serialization boundary, diagnose common performance pitfalls, and walk through four comprehensive quantitative checkpoint problems.
1. Paradigm Shift: Pages Router vs. App Router#
To understand the architectural necessity of the App Router, we must examine the structural limitations of the legacy Pages Router.
+-------------------------------------------------------------------------------------------------+
| PAGES ROUTER VS. APP ROUTER TOPOLOGY |
| |
| Legacy Pages Router (/pages) Modern App Router (/app with RSC) |
| |
| 1. Monolithic Client Bundle: 1. Zero Client JS for Server Components: |
| Every component in the tree is bundled Components stay on server; only raw |
| and sent to the browser, even if it HTML + Flight data streamed to client. |
| renders purely static markdown or text. |
| |
| 2. Layout Destruction on Navigation: 2. Persistent Nested Layouts: |
| Navigating between routes remounts the Layouts preserve DOM, scroll position, |
| entire component tree, destroying local and React state across route changes. |
| state and triggering full re-renders. |
| |
| 3. Page-Level Data Fetching: 3. Component-Level Colocated Fetching: |
| Data fetching was centralized in page-level Any Server Component can directly fetch |
| functions (getServerSideProps) and drilled data or query databases asynchronously |
| down as props through multiple tiers. via async/await. |
+-------------------------------------------------------------------------------------------------+The React Server Components (RSC) Foundation#
React Server Components are a new kind of component that executes exclusively on the server during the build or request phase:
- Server Components never execute in the browser.
- They can directly access backend resources (PostgreSQL via ORMs, Redis caches, filesystems, internal microservices) with zero network latency.
- Their source code and dependencies (e.g., heavy parsing libraries like
markdown-it,date-fns, or cryptographic utilities) are completely excluded from the client JavaScript bundle.
The React Flight Wire Protocol#
When a user navigates between routes in the App Router, the browser does not receive raw HTML alone, nor does it fetch JSON from an ad-hoc REST API. Instead, the server streams the React Flight Protocol—an incremental, line-delimited virtual representation of the component tree.
+-------------------------------------------------------------------------------------------------+
| THE REACT FLIGHT PROTOCOL STREAM |
| |
| Server Execution |
| [Server Component Tree] ---> Serialized to Flight Format (Line-Delimited Stream) |
| | |
| v HTTP Response Stream |
| Client Ingestion: |
| Line 1: 1:I{"id":"./src/components/SearchInput.tsx","chunks":["app/page"],"name":""} |
| Line 2: 0:["","div",null,{"className":"container","children":[["","1",null,{}]]}] |
| | |
| v |
| React Client Runtime merges Flight Stream directly into current Fiber tree! |
| - Replaces Server Component slots with pre-rendered virtual nodes. |
| - Hydrates Client Components (1) without disturbing sibling DOM elements. |
+-------------------------------------------------------------------------------------------------+- Server components are serialized directly into Virtual DOM JSON representations (
["","div",null,{"children":"..."}]). - Client components are serialized as client module references (
1, pointing to their bundle chunk URL). - The client-side React reconciler consumes the stream chunk-by-chunk, seamlessly splicing the new UI into the existing DOM without reloading the page or destroying client state.
2. File-System Routing Conventions in the `app` Directory#
The App Router organizes routes through a nested folder hierarchy, where each folder represents a URL path segment. Special reserved file conventions define the behavior of each segment:
+-------------------------------------------------------------------------------------------------+
| APP ROUTER RESERVED FILE CONVENTIONS |
+-------------------+-----------------------------------------------------------------------------+
| File Name | Architectural Purpose & Runtime Behavior |
+-------------------+-----------------------------------------------------------------------------+
| page.tsx | Defines the unique leaf UI of a route. Makes the folder segment publicly |
| | accessible via HTTP. |
+-------------------+-----------------------------------------------------------------------------+
| layout.tsx | Wraps children in a persistent UI shell. Preserves state and DOM across |
| | child route transitions. Does NOT re-render on sub-route navigation. |
+-------------------+-----------------------------------------------------------------------------+
| template.tsx | Similar to layout, but instantiates a brand new component instance on every |
| | navigation. State is NOT preserved. Triggers entrance animations. |
+-------------------+-----------------------------------------------------------------------------+
| loading.tsx | Wraps the page segment automatically in a React <Suspense> boundary. Renders |
| | an immediate skeleton UI while server components stream data. |
+-------------------+-----------------------------------------------------------------------------+
| error.tsx | Wraps children in a React Error Boundary. Catches runtime errors in subtree |
| | without crashing the parent layout. MUST be a Client Component. |
+-------------------+-----------------------------------------------------------------------------+
| not-found.tsx | Renders when notFound() is invoked or when a URL segment fails to match. |
+-------------------+-----------------------------------------------------------------------------+
| route.ts | Defines an API Route Handler (GET, POST, PUT, DELETE, PATCH). |
+-------------------+-----------------------------------------------------------------------------+Layouts vs. Templates: State Preservation Mechanics#
Understanding when to choose layout.tsx versus template.tsx is essential:
+-------------------------------------------------------------------------------------------------+
| LAYOUT VS. TEMPLATE LIFECYCLE |
| |
| Navigation: /dashboard/analytics --> /dashboard/settings |
| |
| If using layout.tsx: |
| [Root Layout] (Preserved) |
| └── [Dashboard Layout] (PRESERVED: State retained, inputs keep focus, 0 re-mounts) |
| └── [Page] (Swapped: Analytics unmounts, Settings mounts) |
| |
| If using template.tsx: |
| [Root Layout] (Preserved) |
| └── [Dashboard Template] (UNMOUNTS & REMOUNTS: State reset, animations re-trigger) |
| └── [Page] (Swapped: Analytics unmounts, Settings mounts) |
+-------------------------------------------------------------------------------------------------+- `layout.tsx`: Uses React Fiber reconciliation to match the layout node across renders. The layout component function is not re-executed on child navigation. Use for navigation bars, sidebar drawers, audio players, and persistent filters.
- `template.tsx`: React assigns a dynamic
keyderived from the pathname to the template wrapper, forcing the reconciler to discard the old Fiber node and mount a completely fresh instance. Use for page entrance CSS transitions, resetting modal forms, or logging route analytics viauseEffect.
Advanced Routing: Route Groups, Parallel Routes & Intercepting Routes#
- Route Groups `(name)`:
Folders wrapped in parentheses (e.g., app/(marketing)/about and app/(shop)/cart) organize files into logical domain boundaries without altering the URL path structure. They enable assigning different root layouts to different sections of the same domain.
- Parallel Routes (`@slot`):
Allows rendering multiple independent pages simultaneously within the same layout (e.g., @dashboard, @analytics). Each slot has its own independent loading.tsx and error.tsx boundaries.
- Intercepting Routes (`(.)route`, `(..)route`):
Allows intercepting a target route during client-side navigation to display it in a contextual overlay (such as a modal), while displaying the full standalone page on a direct URL load or hard refresh.
- (.): Matches segments on the same level.
- (..): Matches segments one level above.
- (..)(..): Matches segments two levels above.
- (...): Matches segments from the root app directory.
3. The Server-to-Client Serialization Boundary#
In the App Router, every component is a Server Component by default. To introduce browser interactivity, event listeners (onClick, onChange), browser APIs (localStorage, window), or React state hooks (useState, useEffect), you declare the "use client" directive at the top of the file.
+-------------------------------------------------------------------------------------------------+
| THE CLIENT/SERVER BOUNDARY RULES |
| |
| SERVER COMPONENT (Default) |
| - Async data fetching via await db.query() |
| - Direct access to secrets / API keys |
| - Zero bundle impact on client |
| |
| ===================== SERIALIZATION BOUNDARY ("use client") ====================== |
| |
| CLIENT COMPONENT |
| - Interactive hooks: useState, useEffect, useRef |
| - Browser events: onClick, onSubmit, onScroll |
| - Shipped in JavaScript bundle to browser |
+-------------------------------------------------------------------------------------------------+The Serialization Rule#
When a Server Component renders a Client Component and passes props to it:
Every prop passed across the boundary must be serializable by the React Flight protocol.
Allowed Props:
- Primitive values:
string,number,boolean,null,undefined. - Plain objects and arrays containing serializable types.
- Promises (handled seamlessly via React's
use()hook on the client). - React Elements (passed via
children).
Prohibited Props:
- Functions (e.g., passing a database query callback
fetchData={() => db.query()}triggersError: Functions cannot be passed directly to Client Components). - Class instances with methods (e.g.,
new Date()works if serialized, but custom domain classes lose prototypes). - Symbols.
The Interleaving Pattern (Passing Server Components as Children)#
A common misconception is that importing a component into a Client Component makes all child components Client Components. To embed a Server Component inside a Client Component without forcing it to execute on the client, use the Composition Pattern:
// Client Component: ModalShell.tsx
"use client";
import { useState } from "react";
export function ModalShell({ children }: { children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && <div className="modal">{children}</div>}
</div>
);
}
// Server Component: Page.tsx (Parent)
import { ModalShell } from "./ModalShell";
import { HeavyServerDataFeed } from "./HeavyServerDataFeed";
export default function Page() {
return (
<ModalShell>
{/* HeavyServerDataFeed executes ON THE SERVER and streams as children! */}
<HeavyServerDataFeed />
</ModalShell>
);
}4. Production Engineering Case Study: Multi-Slot Financial Command Center#
Consider an institutional trading command center built on Next.js 15:
- Root URL:
/trading - Requires two simultaneous dashboards:
@orderbook(high-frequency DOM streaming) and@positions(ledger reconciliation). - Clicking an asset opens a modal preview via intercepting routes
(.)asset/[id].
Folder Architecture#
app/
├── trading/
│ ├── layout.tsx # Renders both parallel slots
│ ├── page.tsx # Default trading landing view
│ ├── @orderbook/
│ │ ├── page.tsx # Live orderbook streaming view
│ │ └── loading.tsx # Orderbook skeleton
│ ├── @positions/
│ │ ├── page.tsx # Position risk table
│ │ └── error.tsx # Independent error boundary
│ └── (.)asset/[id]/
│ └── page.tsx # Intercepted modal preview
└── asset/[id]/
└── page.tsx # Full-page asset details viewLayout Implementation#
// app/trading/layout.tsx
export default function TradingLayout({
children,
orderbook,
positions,
modal,
}: {
children: React.ReactNode;
orderbook: React.ReactNode;
positions: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div className="trading-container grid grid-cols-12 gap-4 p-6">
<div className="col-span-12">{children}</div>
<div className="col-span-8">{orderbook}</div>
<div className="col-span-4">{positions}</div>
{modal}
</div>
);
}If @positions encounters an API timeout, its local error.tsx boundary activates, displaying a retry button without affecting the live `@orderbook` slot, achieving true micro-frontend fault isolation within a single repository.
5. Anti-Patterns & Common Pitfalls#
The "Overuse of use client" Anti-Pattern#
Developers migrating from Pages Router often place "use client" at the top of every file out of habit. This completely eliminates the performance advantages of Server Components, forcing the browser to download, parse, and execute hundreds of kilobytes of unnecessary JavaScript.
Remediation: Push "use client" down to the leaf nodes of your component tree. A button that toggles state should be a Client Component, but the card containing it, the data fetching logic, and the surrounding layout should remain Server Components.
The Request Waterfall Anti-Pattern in Server Components#
Executing sequential asynchronous fetches in nested Server Components without Suspense boundaries creates network waterfalls:
// ANTI-PATTERN: Sequential Waterfall
export default async function DashboardPage() {
const user = await fetchUser(); // Takes 200 ms
const orders = await fetchOrders(user.id); // Takes 300 ms (Total: 500 ms before ANY HTML streams!)
return <Dashboard user={user} orders={orders} />;
}Remediation: Use Promise.all for independent parallel fetches, or wrap individual subcomponents in <Suspense> boundaries to allow HTML streaming to begin immediately:
// IDIOMATIC: Parallel Fetching
const [user, orders] = await Promise.all([fetchUser(), fetchOrders()]);6. Checkpoint Practice Problems#
The following four multi-part quantitative problems evaluate React Flight wire protocol decoding, layout vs. template state preservation, parallel/intercepting route resolution matrices, and serialization boundary validation. Complete step-by-step solutions follow each problem prompt.
Problem 1: React Server Component (RSC) Flight Protocol Payload Deconstruction#
Context:
A software engineer inspects the raw HTTP response stream of a Next.js App Router page navigation in Chrome DevTools. The server emits the following line-delimited React Flight protocol payload:
1:I{"id":"./src/components/CurrencyConverter.tsx","chunks":["app/converter_chunk.js"],"name":"CurrencyConverter"}
2:{"title":"Global FX Rates","baseCurrency":"USD"}
0:["","main",null,{"className":"fx-container","children":[["","h1",null,{"children":"FX Dashboard"}],["","L1",null,{"config":"2"}],["","footer",null,{"children":"DevThesis Systems"}]]}]Questions:
- Deconstruct the payload line-by-line, explaining the role of each identifier (
1:I,2:,0:). - Identify which component is a Client Component and which components are Server Components.
- Trace how the client-side React reconciler reconstructs the final Virtual DOM tree from this payload, explaining the references
L1and2. - If the client component bundle
converter_chunk.jsis 14 KB, whereas the total server-rendered HTML and Flight payload is only 1.2 KB, calculate the percentage JavaScript bundle reduction achieved by keepingh1,main, andfooteras Server Components compared to a traditional SPA bundling all four elements.
Solution:
Step 1: Payload Deconstruction
- Line 1 (`1:I{...}`):
Defines an Import / Client Module Reference.
- ID 1: The unique module reference index within this stream.
- Flag I: Specifies an Import statement.
- Payload: Instructs the browser to load the Client Component CurrencyConverter from the chunk file app/converter_chunk.js.
- Line 2 (`2:{...}`):
Defines a Serialized Data Model Object.
- ID 2: A plain JavaScript object { title: "Global FX Rates", baseCurrency: "USD" } passed as a prop across the serialization boundary.
- Line 3 (`0:[...]`):
Defines the Root Virtual DOM Tree Node (ID 0).
- Tag `: Represents a React element (React.createElement`).
- Type "main": Standard HTML element.
- Props: Contains className: "fx-container" and three children.
Step 2: Component Classification
- Server Components:
- <main>, <h1>, and <footer>.
- These elements execute exclusively on the server and are emitted directly as virtual node arrays. Zero JavaScript code for these tags is shipped to the client!
- Client Component:
- CurrencyConverter (referenced via L1).
- Emitted as an external module reference, requiring the browser to fetch and execute converter_chunk.js.
Step 3: Virtual DOM Tree Reconstruction
When the browser processes the root array (Line 3):
- Element 1:
["", "h1", null, {"children": "FX Dashboard"}]-> Instantiates an<h1>node. - Element 2:
["", "L1", null, {"config": "2"}]->
- Resolves L1 to the imported client component CurrencyConverter from Module 1.
- Resolves 2 to the configuration data object from Model 2.
- Renders <CurrencyConverter config={{ title: "Global FX Rates", baseCurrency: "USD" }} />.
- Element 3:
["", "footer", null, {"children": "DevThesis Systems"}]-> Instantiates a<footer>node.
Step 4: Bundle Reduction Calculation
- Traditional SPA Model (all components bundled in client JS):
Bundle includes main, h1, footer, React framework wrappers, and CurrencyConverter:
Total client JS = 14 KB + 28 KB (layout, typography, DOM template builders) = 42 KB.
- RSC App Router Model:
Only the client component is downloaded: 14 KB client JS + 1.2 KB wire payload = 15.2 KB.
- Reduction = (42 - 15.2) / 42 × 100\% ≈ 63.8\% reduction in network bundle payload.
Problem 2: Next.js Layout vs. Template Lifecycle & State Preservation#
Context:
An engineering team is building an enterprise settings console with two nested sub-routes:
- Route 1:
/settings/profile - Route 2:
/settings/billing
The team evaluates two different wrapper architectures for the /settings folder:
- Architecture A: Implemented via
app/settings/layout.tsx - Architecture B: Implemented via
app/settings/template.tsx
Inside the wrapper component, the team includes a search input with local state:
const [searchFilter, setSearchFilter] = useState("init_val");A user visits /settings/profile, types "enterprise_tax" into the input (setting searchFilter = "enterprise_tax"), and then clicks a <Link href="/settings/billing"> to transition routes via client-side navigation.
Questions:
- In Architecture A (
layout.tsx), what is the value ofsearchFilterimmediately after the route transition to/settings/billingcompletes? Does the<input>retain DOM focus? - In Architecture B (
template.tsx), what is the value ofsearchFilterimmediately after the route transition completes? - Explain the internal React reconciliation reason why Architecture A preserves state while Architecture B destroys state.
- If the developer wants to trigger a Framer Motion entrance fade-in animation on every transition between
/settings/profileand/settings/billing, which architecture must be chosen and why?
Solution:
Step 1: Architecture A (`layout.tsx`) Outcome
searchFiltervalue: `"enterprise_tax"` (Preserved!).- DOM Focus: The
<input>retains active keyboard focus without interruption.
Step 2: Architecture B (`template.tsx`) Outcome
searchFiltervalue: `"init_val"` (Reset to initial default!).- DOM Focus: Focus is lost as the input element is unmounted and recreated.
Step 3: Internal Reconciliation Mechanics
- In Architecture A (
layout.tsx):
The parent Fiber node representing the layout shares the identical component type and Fiber key across navigations:
current.type === workInProgress.type && current.key === workInProgress.key.
React's reconciler bails out of re-rendering the layout node, preserves its memoizedState hook linked list, and swaps only the Fiber subtree attached to {children} (replacing the Profile Page Fiber with the Billing Page Fiber).
- In Architecture B (
template.tsx):
Next.js wraps templates in a unique layout boundary that injects a dynamic key matching the current segment path: <Template key={pathname}>{children}</Template>.
Because the key changes from "/settings/profile" to "/settings/billing", React's reconciler treats the old Template as an unmounted component, deletes its entire Fiber node from memory, garbage-collects its state, and mounts a completely fresh Fiber instance.
Step 4: Recommendation for Page Entrance Animations
Choose Architecture B (`template.tsx`).
Because layout.tsx does not remount on sub-route transitions, CSS animation classes (like @keyframes fadeIn) and animation hook lifecycles (e.g., Framer Motion's initial -> animate) will not re-trigger when moving between siblings. template.tsx forces unmounting and remounting on every navigation, reliably re-triggering entrance animations.
Problem 3: Parallel & Intercepting Routes State Resolution Matrix#
Context:
An image sharing platform implements an Instagram-style feed with modal photo previews:
- Feed URL:
/feed - Photo View URL:
/photo/[id]
The directory structure is organized with parallel slot @modal and intercepting route (.)photo/[id]:
app/
├── feed/
│ ├── layout.tsx # export default function Layout({ children, modal })
│ ├── page.tsx # Renders grid of photo thumbnails with <Link href={`/photo/{id}`}>
│ ├── @modal/
│ │ ├── (.)photo/[id]/
│ │ │ └── page.tsx # Modal Overlay Component
│ │ └── default.tsx # export default function Default() { return null; }
│ └── default.tsx
└── photo/[id]/
└── page.tsx # Full standalone page viewScenarios:
- Action 1: User visits
/feeddirectly via browser address bar. - Action 2: User clicks thumbnail for photo
101(<Link href="/photo/101">). - Action 3: User presses the browser "Back" button.
- Action 4: User visits
/photo/101directly via hard refresh / URL paste.
Questions:
- For Action 1, what does the
@modalslot render, and why isdefault.tsxmandatory? - For Action 2 (Soft Navigation), what page renders inside the
@modalslot, and what renders in the mainchildrenslot? What is the browser URL? - For Action 3, what occurs in the UI and browser history?
- For Action 4 (Hard Navigation), what component renders? Does the modal appear?
Solution:
Step 1: Action 1 (Direct Navigation to `/feed`)
- Main
childrenslot: Rendersapp/feed/page.tsx(the photo grid). @modalslot: Renders `null` (viaapp/feed/@modal/default.tsx).- Why `default.tsx` is mandatory: In parallel routing, if Next.js cannot match a slot on the initial page load, it looks for
default.tsxas a fallback. Ifdefault.tsxis omitted, Next.js throws an unhandled 404 error for the entire route!
Step 2: Action 2 (Soft Navigation via `<Link href="/photo/101">`)
- Browser URL changes to: `/photo/101`.
@modalslot: Intercepted byapp/feed/@modal/(.)photo/[id]/page.tsx! Renders the photo in a modal overlay.- Main
childrenslot: Continues renderingapp/feed/page.tsxin the background beneath the modal. - User Experience: The user sees the modal overlaid on top of the feed, while the URL bar displays
/photo/101.
Step 3: Action 3 (Browser Back Button)
- Next.js pops the browser history stack back to
/feed. - The intercepting route unmounts.
@modalreverts todefault.tsx(returningnull), closing the modal.- The feed remains on screen with its scroll position intact.
Step 4: Action 4 (Hard Navigation / Page Refresh on `/photo/101`)
- Direct server request for
/photo/101. - Next.js evaluates routes without client-side interception.
- The standalone page `app/photo/[id]/page.tsx` renders as a complete full-page view.
- The modal does NOT appear, and the feed is not loaded in the background.
Problem 4: Server-to-Client Serialization Boundary Validation#
Context:
A senior full-stack developer conducts a code review on a pull request for an e-commerce checkout page. The author attempts to pass several props from an async Server Component to an interactive Client Component:
// Server Component: app/checkout/page.tsx
import { CheckoutForm } from "./CheckoutForm";
import { db } from "@/lib/db";
export default async function CheckoutPage() {
const orderId = 94821;
const user = await db.getUser(42);
const discountPromise = db.getDiscountCode(orderId);
const createdAt = new Date();
const calculateTotal = (taxRate: number) => {
return user.balance * (1 + taxRate);
};
return (
<CheckoutForm
orderId={orderId}
user={user}
discountPromise={discountPromise}
createdAt={createdAt}
onCalculateTotal={calculateTotal}
/>
);
}// Client Component: app/checkout/CheckoutForm.tsx
"use client";
export function CheckoutForm({
orderId,
user,
discountPromise,
createdAt,
onCalculateTotal
}: any) {
return <div>Checkout Form</div>;
}Questions:
- Inspect all 5 props passed to
CheckoutForm. Identify which props violate React Flight serialization rules and will crash the application during build or runtime. - For each invalid prop, explain the exact serialization error thrown by Next.js.
- Show how
discountPromisecan be consumed safely inside the Client Component using React's modernuse()hook without triggering serialization errors. - Refactor the implementation to eliminate all serialization violations while preserving the dynamic calculation functionality using Server Actions.
Solution:
Step 1: Evaluation of the 5 Props
orderId(Number:94821): VALID. Primitive integer.user(Plain DB Object:{ id: 42, name: "Alice", balance: 150 }): VALID if a plain JSON object. (If an ORM model instance with methods, invalid).discountPromise(Promise Object): VALID. React Flight natively serializes Promises across the boundary!createdAt(new Date()instance): VALID. React Flight serializesDateobjects into ISO strings and reconstructs them asDateinstances on the client.onCalculateTotal(Arrow Function:(taxRate) => ...): INVALID (VIOLATION).
Step 2: Exact Error Output
Passing onCalculateTotal triggers:
Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server". <... onCalculateTotal={function}>
Step 3: Consuming `discountPromise` via `use()`
Inside CheckoutForm.tsx:
"use client";
import { use } from "react";
export function CheckoutForm({ discountPromise }: { discountPromise: Promise<string> }) {
// Unwraps the Promise directly inside render; suspends parent Suspense boundary until resolved!
const discountCode = use(discountPromise);
return <div>Active Discount: {discountCode}</div>;
}Step 4: Architectural Refactoring via Server Actions
To allow the client to trigger server-side calculations without violating serialization rules, convert calculateTotal into a Server Action marked with "use server":
// app/actions/checkout.ts
"use server";
import { db } from "@/lib/db";
export async function calculateOrderTotal(userId: number, taxRate: number): Promise<number> {
const user = await db.getUser(userId);
return user.balance * (1 + taxRate);
}Refactored Server Component:
// app/checkout/page.tsx
import { CheckoutForm } from "./CheckoutForm";
import { calculateOrderTotal } from "@/app/actions/checkout";
import { db } from "@/lib/db";
export default async function CheckoutPage() {
const orderId = 94821;
const user = await db.getUser(42);
const discountPromise = db.getDiscountCode(orderId);
const createdAt = new Date();
return (
<CheckoutForm
orderId={orderId}
user={{ id: user.id, name: user.name, balance: user.balance }}
discountPromise={discountPromise}
createdAt={createdAt}
onCalculate={calculateOrderTotal} // Server Action reference is serializable RPC endpoint!
/>
);
}