React Server Components 03 🧬 The Build-Time Model — Rendering before bundling, without a server
The “server” in React Server Components does not have to be a running web server. It is any environment separate from the client application and the SSR server, and it can execute once during the build, on a CI machine, before any bundle is produced . This is the build-time model: the Server Component reads the filesystem, fetches the static content, and renders once. The output is then serialized into HTML and the RSC Payload, and the client never downloads the component code or the expensive libraries it depends on.
The previous chapter separated RSC from SSR and Client Components. This chapter focuses on the build-time variant specifically, because it is the mode that makes RSC useful for static sites, documentation, blogs, and the content-heavy pages that do not change per request. The Next.js static export (output: 'export') is the concrete implementation: the Server Components run during next build, and the result is a set of static HTML files and static RSC payloads that can be served from a CDN or any static host .
Key point: The build-time model has no request. There is no cookies(), no headers(), and no dynamic data that depends on the visitor. The Server Component runs once, its output is frozen into the build artifact, and every visitor receives the same rendered result . The benefit is the bundle reduction and the first-paint visibility; the constraint is that anything requiring per-request data must be moved to a Client Component or a runtime server.
Why the build-time model exists
The static-content problem.
A documentation page or a blog post is static content that does not change between requests. Without RSC, the common pattern is to ship the Markdown parser and the sanitizer to the browser, fetch the content from an API, and render it client-side. The React documentation uses the example of marked (35.9K, 11.2K gzipped) and sanitize-html (206K, 63.3K gzipped), which means the user downloads roughly 75K gzipped of libraries just to render text that will never change during the page’s lifetime . With RSC at build time, the parser and the sanitizer run on the CI machine, and the client receives only the rendered HTML.
The build-artifact problem.
The build-time model produces a deterministic artifact. The Server Component’s output is rendered into static HTML for the initial page load and a static RSC payload for the client-side navigation between the routes . The artifact can be uploaded to a CDN, served from an Nginx container, or hosted on any platform that serves static files. There is no Node.js runtime required, no serverless function, and no per-request compute .
The async-component problem.
The Server Component can be an async function, and it can await the filesystem or the static API during the build . This is a feature of the Server Component type that is not available in the Client Components, because the browser cannot suspend the render on a promise in the same way. The async component is the mechanism that makes the build-time data access natural: the component reads the content, awaits the result, and returns the JSX.
a. The Server Component at build time
The Server Component is an async function with no directive. It reads the static content and returns the JSX. During next build, the component runs once, and the output is captured.
// app/posts/[slug]/page.tsx (Server Component)
import { readFile } from 'fs/promises'
import { marked } from 'marked'
import sanitizeHtml from 'sanitize-html'
export default async function PostPage({ params }: { params: { slug: string } }) {
const content = await readFile(`./content/${params.slug}.md`, 'utf-8')
const html = sanitizeHtml(marked(content))
return <article dangerouslySetInnerHTML={{ __html: html }} />
}
The readFile and the marked and the sanitizeHtml imports run on the build machine . They never reach the browser, because the Server Component’s module is not in the client bundle. The rendered output—the sanitized HTML—is written to the static file. The client receives the HTML and none of the libraries that produced it.
For the static export, the dynamic route needs the generateStaticParams function to enumerate the slugs at build time. Without it, the dynamic route cannot be statically exported, because the build does not know which pages to generate .
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
const files = await readdir('./content')
return files.map((file) => ({ slug: file.replace(/\.md$/, '') }))
}
The generateStaticParams runs during the build, returns the list of the slugs, and Next.js generates one static page per slug. The dynamic route becomes a set of static files, and the Server Component runs once per slug.
b. The static export configuration
The static export is enabled in the next.config.js with the output: 'export' option. After next build, the out directory contains the HTML, the CSS, the JS, and the static RSC payloads .
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
}
module.exports = nextConfig
The out directory contains the rendered HTML for each route, the client bundles for the Client Components, and the static RSC payload for the client navigation . The payload is what the client fetches when the user navigates between the routes after the initial load. The initial load uses the static HTML, and the navigation uses the static payload. Both are produced at build time, and neither requires a server.
The static export does not support the features that require a running server: the Server Actions, the middleware, the dynamic route handlers that read the request, and the cookies() and the headers() functions . The Server Components that use the static data are supported, and they run during the build.
c. The runtime versus the build-time trade-off
The same Server Component can run at build time or at request time. The choice depends on the data. A component that reads the static Markdown from the filesystem runs at build time. A component that reads the user’s session from the cookies runs at request time, on a server .
| Data Source | Build Time | Request Time |
|---|---|---|
| Filesystem (static content) | Yes | Yes |
| Static API (CMS) | Yes | Yes |
| Database (per request) | No | Yes |
| Cookies / headers | No | Yes |
| User session | No | Yes |
The build-time model is the correct choice for the content that does not change per visitor. The request-time model is required for the personalized content. The Next.js App Router supports both in the same application: the static pages are exported at build time, and the dynamic pages run on a server (or a serverless function) at request time .
Complete Example Session
// ============================================
// PART 1: THE STATIC CONTENT SOURCE
// ============================================
// ./content/hello.md contains the Markdown text.
// ============================================
// PART 2: THE SERVER COMPONENT
// ============================================
// app/posts/[slug]/page.tsx
import { readFile } from 'fs/promises'
import { marked } from 'marked'
import sanitizeHtml from 'sanitize-html'
export default async function PostPage({ params }: { params: { slug: string } }) {
const content = await readFile(`./content/${params.slug}.md`, 'utf-8')
const html = sanitizeHtml(marked(content))
return <article dangerouslySetInnerHTML={{ __html: html }} />
}
// ============================================
// PART 3: THE STATIC PARAMS
// ============================================
// app/posts/[slug]/page.tsx
import { readdir } from 'fs/promises'
export async function generateStaticParams() {
const files = await readdir('./content')
return files.map((file) => ({ slug: file.replace(/\.md$/, '') }))
}
// ============================================
// PART 4: THE STATIC EXPORT CONFIG
// ============================================
// next.config.js
const nextConfig = { output: 'export' }
module.exports = nextConfig
// ============================================
// PART 5: THE BUILD OUTPUT
// ============================================
// After next build:
// out/index.html
// out/posts/hello.html
// out/_next/static/... (the client bundles)
// The HTML contains the rendered Markdown.
// The client bundle does not contain marked or sanitize-html.
// ============================================
// PART 6: THE CLIENT NAVIGATION
// ============================================
// The client-side navigation fetches the static RSC payload
// from the build output, not from a server.
The six parts demonstrate the build-time model: the content source, the Server Component with the filesystem access, the static params for the dynamic route, the static export configuration, the build output, and the client navigation using the static payload.
Quick Reference
Build-Time Properties
| Property | Build Time | Request Time |
|---|---|---|
| Server required | No | Yes |
| Per-request data | No | Yes |
| Filesystem access | Yes | Yes |
| Cookies/headers | No | Yes |
| Output | Static HTML + RSC payload | Dynamic HTML |
| Deployment | CDN / static host | Node.js server |
Static Export Options
| Option | Value | Purpose |
|---|---|---|
output | 'export' | Enable static export |
trailingSlash | true/false | URL trailing slash |
distDir | 'out' | Output directory |
Static Export Support
| Feature | Supported |
|---|---|
| Server Components (static) | Yes |
| Client Components | Yes |
| Static Route Handlers (GET) | Yes |
| Dynamic Route Handlers | No |
| Server Actions | No |
| Middleware | No |
Best Practices
✅ Do This:
// Use the Server Component for the static content
export default async function Page() { ... } # ✅
// Use generateStaticParams for the dynamic routes
export async function generateStaticParams() { ... } # ✅
// Enable the static export in the config
output: 'export' # ✅
// Keep the heavy libraries in the Server Component
import { marked } from 'marked' // build-time only # ✅
// Use the build-time model for the content that does not change
// per visitor # ✅
❌ Don’t Do This:
// Use the cookies() in the static export
cookies() // Not supported in the static export # ❌
// Use the Server Actions in the static export
'use server' // Not supported # ❌
// Use the dynamic route without generateStaticParams
// The build cannot enumerate the pages # ❌
// Ship the markdown parser to the client
import { marked } from 'marked' // in a Client Component # ❌
// Expect the per-request data at build time
// The build has no request # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The build fails on the dynamic route | No generateStaticParams | Add the function |
| The cookies are empty | No request at build time | Move to a runtime server |
| The Server Action fails | Not supported in the static export | Move to a runtime server |
| The heavy library is in the client bundle | Imported in a Client Component | Move to a Server Component |
| The static page shows the stale data | Built once, never revalidated | Rebuild or use ISR |
Real-World Examples
1. The static Markdown page
async function Page({ params }) {
const content = await readFile(`./content/${params.slug}.md`, 'utf-8')
return <article>{marked(content)}</article>
}
2. The static params
export async function generateStaticParams() {
const files = await readdir('./content')
return files.map((file) => ({ slug: file.replace(/\.md$/, '') }))
}
3. The static export
const nextConfig = { output: 'export' }
4. The build command
next build
5. The output directory
ls out/
# index.html, posts/hello.html, _next/static/
6. The CDN upload
aws s3 sync out/ s3://my-bucket/
7. The Nginx config
root /var/www/out;
location / { try_files $uri $uri.html $uri/ =404; }
8. The client navigation
<Link href="/posts/hello">Hello</Link>
9. The runtime server
// Not exported; runs on the server
export default async function Page() {
const session = await getSession()
return <div>{session.user.name}</div>
}
10. The mixed application
Static pages: exported at build time
Dynamic pages: rendered on the server
Both in the same app.
Visual
The Build-Time Flow
┌──────────────────────────────────────────────────────────────┐
│ THE BUILD RUNS THE SERVER COMPONENTS ONCE │
│ │
│ CI machine (next build) │
│ ├── reads the filesystem │
│ ├── fetches the static content │
│ ├── runs the Server Components │
│ └── produces the out/ directory │
│ ├── the static HTML │
│ ├── the static RSC payload │
│ └── the client bundles │
│ │
│ The heavy libraries stay on the CI machine. │
└──────────────────────────────────────────────────────────────┘
The Build-Time vs the Request-Time
┌──────────────────────────────────────────────────────────────┐
│ THE CHOICE DEPENDS ON THE DATA │
│ │
│ Build time: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ The static content, the filesystem, the CMS │ │
│ │ The output is frozen into the build artifact │ │
│ │ The deployment: the CDN, the static host │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Request time: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ The user session, the cookies, the database │ │
│ │ The output is rendered per request │ │
│ │ The deployment: the Node.js server, the serverless │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The Static Export Output
┌──────────────────────────────────────────────────────────────┐
│ THE out/ DIRECTORY CONTAINS THE STATIC SITE │
│ │
│ out/ │
│ ├── index.html ← the / route │
│ ├── posts/ │
│ │ └── hello.html ← the /posts/hello route │
│ ├── _next/static/ ← the client JS/CSS │
│ └── ... │
│ │
│ The HTML is served directly. │
│ The client JS hydrates the Client Components. │
│ No server is required. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Build-time model | The Server Components run once at the build |
| Server required | No |
| Per-request data | Not supported |
| Filesystem access | Yes |
| Static export | output: 'export' |
| Static params | generateStaticParams |
| Output | out/ directory |
| Deployment | CDN, Nginx, static host |
| Heavy libraries | Stay on the build machine |
| Client navigation | Static RSC payload |
Key takeaways:
- The “server” in RSC does not have to be a running server. It is any environment separate from the client and the SSR server, and the build-time model runs the Server Components once on the CI machine before the bundling .
- The build-time model is for the static content. The filesystem reads, the static CMS fetches, and the Markdown parsing run during the build, and the client receives only the rendered HTML and the static RSC payload .
- The heavy libraries stay out of the client bundle. The
markedand thesanitize-htmlexamples show the 75K gzipped of libraries that the client no longer downloads when the parsing happens at build time . - The dynamic routes need
generateStaticParams. The build cannot enumerate the dynamic pages without the function that returns the list of the params, so the static export requires it for the dynamic routes . - The static export does not support the per-request features. The cookies, the headers, the Server Actions, and the middleware are not available in the static export, because there is no request at the build time .
- The same component can run at build time or request time. The choice depends on the data: the static content runs at build time, and the personalized content runs at request time on a server .
Remember: The build-time model is the RSC without a server. The Server Components run once during the build, and the output is frozen into the static HTML and the static RSC payload. The heavy libraries stay on the CI machine, and the client receives only the rendered result. The dynamic routes need the generateStaticParams, and the per-request features are not supported. The result is the static site with the full server-side data access and the minimal client bundle, which is the difference between the SPA that ships the parser and the static page that ships the HTML.
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!