React Server Components 04 🧬 The Request-Time Model — Accessing data layers directly per request
The request-time model is the second execution environment for React Server Components. The previous chapter covered the build-time model, where the components run once during the build and the output is frozen into a static artifact. The request-time model runs the Server Components on a web server for each incoming request, which means the component can read the per-request data: the user’s session, the cookies, the headers, and the live database state . The “server” in RSC is any separate environment, and the request-time model is the mode that makes the RSC a full replacement for the traditional server-rendered application.
The defining property is the direct data access. A Server Component can import the database client or the ORM and query the data layer without building an API route in between . The credentials and the query logic never reach the client, because the component code is not part of the client bundle. The previous chapters covered the composition and the boundary; this chapter covers the data-access pattern that makes the request-time model useful for the dynamic, personalized pages.
Key point: The request-time Server Component is the same component type as the build-time one. The difference is the execution context: the build-time component runs once on the CI machine, and the request-time component runs on the web server for each request. The request-time component can call cookies() or headers() to read the incoming request, and it can query the database with the user’s context. The data is rendered into the RSC Payload and sent to the client, and the client never sees the database client or the SQL.
Why the request-time model requires the direct data access
The API-layer problem.
Without the Server Components, the common pattern is to fetch the dynamic data on the client with an Effect, which requires an API route that reads the database and returns the JSON . The client downloads the JavaScript, mounts the component, sends the request to the API, waits for the response, and renders the data. The waterfall is the cost: the page loads, the component mounts, the API call fires, and the data arrives after the first render. The request-time Server Component eliminates the waterfall by reading the data during the render, before the HTML is sent.
The credentials problem.
The database client requires the connection string or the secret key. If the client-side code queries the database directly, the secret is exposed in the browser. The Server Component keeps the secret on the server, and the client receives only the rendered output . The server-only package is the mechanism that enforces this at the build time: if a Client Component imports a module marked with import 'server-only', the build fails .
The per-request problem.
The build-time model cannot read the user’s session or the live database state, because there is no request at the build time. The request-time model runs the component on the web server, where the cookies() and the headers() functions are available, and where the database query reflects the current state . The request-time model is the correct choice for the personalized content, the authenticated pages, and the data that changes between the requests.
a. The request-time Server Component and the data access
The request-time Server Component is an async function that reads the data during the render. It can import the database client and the query directly.
// app/notes/[id]/page.tsx (Server Component)
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
export default async function NotePage({ params }: { params: { id: string } }) {
const cookieStore = await cookies()
const sessionId = cookieStore.get('session')?.value
const note = await db.notes.findUnique({
where: { id: params.id },
include: { author: true },
})
if (!note) {
return <div>Note not found</div>
}
return (
<article>
<h1>{note.title}</h1>
<p>By {note.author.name}</p>
<div>{note.content}</div>
</article>
)
}
The cookies() call reads the incoming request’s cookies, and the db.notes.findUnique query reads the database . The db module and the query never reach the client, because the Server Component’s code is not bundled. The rendered output—the note’s title, author, and content—is serialized into the RSC Payload and sent to the client.
The server-only package is the guard that prevents the accidental import of the database module into a Client Component.
// lib/db.ts
import 'server-only'
export const db = new Database(process.env.DATABASE_URL)
The import 'server-only' statement tells the bundler that the module must not be included in the client bundle. If a Client Component tries to import db.ts, the build fails with an error . The guard is the mechanism that catches the mistake before the secret reaches the browser.
b. The request-time rendering and the streaming
The request-time Server Component can stream the rendered output to the client as the data resolves. The Suspense boundary is the mechanism that shows the fallback while the data is loading and streams the content when it is ready.
// app/notes/[id]/page.tsx
import { Suspense } from 'react'
import NoteContent from './note-content'
import Comments from './comments'
export default function NotePage({ params }: { params: { id: string } }) {
return (
<div>
<Suspense fallback={<NoteSkeleton />}>
<NoteContent id={params.id} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments noteId={params.id} />
</Suspense>
</div>
)
}
The Suspense boundary wraps the NoteContent and the Comments, and the fallback is shown while the data is loading . The two components can load in parallel, and the page streams the content as each one resolves. The streaming is the mechanism that improves the perceived performance: the user sees the page shell immediately, and the content arrives as it becomes available.
The parallel data fetching is the pattern that avoids the sequential waterfall.
// app/notes/[id]/page.tsx (Server Component)
export default async function NotePage({ params }: { params: { id: string } }) {
// Start both requests immediately
const notePromise = getNote(params.id)
const commentsPromise = getComments(params.id)
// Await both in parallel
const [note, comments] = await Promise.all([notePromise, commentsPromise])
return (
<div>
<NoteContent note={note} />
<Comments comments={comments} />
</div>
)
}
The getNote and the getComments calls start immediately, and the Promise.all awaits both . Without the Promise.all, the second call would wait for the first to finish, which is the sequential waterfall. The parallel fetching is the pattern that makes the request-time model fast.
c. The request-time versus the build-time trade-off
The same component can run at build time or request time, and the choice depends on the data source.
| Data Source | Build Time | Request Time |
|---|---|---|
| Static Markdown | Yes | Yes |
| User session | No | Yes |
| Live database | No | Yes |
| Cookies/headers | No | Yes |
| Static CMS | Yes | Yes |
The build-time model is for the content that does not change per visitor, and the request-time model is for the personalized content . The Next.js App Router supports both in the same application: the static pages are built once, and the dynamic pages run on the server for each request. The connection() function is the mechanism that marks a read as request-time, which opts the route into the dynamic rendering .
Complete Example Session
// ============================================
// PART 1: THE SERVER-ONLY MODULE
// ============================================
// lib/db.ts
import 'server-only'
import { Database } from './database-client'
export const db = new Database(process.env.DATABASE_URL)
// ============================================
// PART 2: THE REQUEST-TIME SERVER COMPONENT
// ============================================
// app/notes/[id]/page.tsx
import { db } from '@/lib/db'
import { cookies } from 'next/headers'
export default async function NotePage({ params }: { params: { id: string } }) {
const cookieStore = await cookies()
const note = await db.notes.findUnique({
where: { id: params.id },
include: { author: true },
})
if (!note) return <div>Note not found</div>
return (
<article>
<h1>{note.title}</h1>
<p>By {note.author.name}</p>
<div>{note.content}</div>
</article>
)
}
// ============================================
// PART 3: THE STREAMING WITH SUSPENSE
// ============================================
// app/notes/[id]/page.tsx
import { Suspense } from 'react'
import Comments from './comments'
export default function NotePage({ params }: { params: { id: string } }) {
return (
<div>
<Suspense fallback={<NoteSkeleton />}>
<NoteContent id={params.id} />
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<Comments noteId={params.id} />
</Suspense>
</div>
)
}
// ============================================
// PART 4: THE PARALLEL DATA FETCHING
// ============================================
async function NoteContent({ id }: { id: string }) {
const note = await getNote(id)
return <article>{note.content}</article>
}
async function Comments({ noteId }: { noteId: string }) {
const comments = await getComments(noteId)
return <ul>{comments.map((c) => <li key={c.id}>{c.text}</li>)}</ul>
}
// ============================================
// PART 5: THE BUILD-TIME VERSUS REQUEST-TIME
// ============================================
// The static page: built once, no request
// The dynamic page: rendered per request, reads the session
// The same component type, different execution context
// ============================================
// PART 6: THE SERVER-ONLY GUARD
// ============================================
// If a Client Component imports lib/db.ts, the build fails.
// The secret never reaches the browser.
The six parts demonstrate the request-time model: the server-only guard, the request-time data access, the streaming with Suspense, the parallel fetching, the build-time versus request-time distinction, and the server-only guard.
Quick Reference
Request-Time Properties
| Property | Request Time |
|---|---|
| Server required | Yes |
| Per-request data | Yes |
| Cookies/headers | Yes |
| Live database | Yes |
| Static export | No |
| Deployment | Node.js server, serverless |
Data Access
| API | Purpose |
|---|---|
cookies() | Read the request cookies |
headers() | Read the request headers |
db.query() | Query the database directly |
Promise.all | Parallel data fetching |
Streaming
| API | Purpose |
|---|---|
<Suspense> | Stream the content as it resolves |
fallback | The loading UI |
loading.tsx | The route-level fallback |
Security
| Guard | Purpose |
|---|---|
import 'server-only' | Prevent the client import |
server-only package | Build-time enforcement |
Best Practices
✅ Do This:
// Use the Server Component for the per-request data
export default async function Page() { ... } # ✅
// Use the server-only guard for the database module
import 'server-only' # ✅
// Use the Promise.all for the parallel data
await Promise.all([getNote(), getComments()]) # ✅
// Use the Suspense for the streaming
<Suspense fallback={<Skeleton />}><Content /></Suspense> # ✅
// Read the cookies in the request-time component
const cookieStore = await cookies() # ✅
❌ Don’t Do This:
// Import the database into a Client Component
'use client'
import { db } from '@/lib/db' // Build error # ❌
// Use the sequential await for the independent data
const note = await getNote()
const comments = await getComments() // Waterfall # ❌
// Use the build-time model for the user session
// The build has no request # ❌
// Expose the database secret in the client
// The secret must stay on the server # ❌
// Use the static export for the dynamic page
output: 'export' // Not supported # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The database secret is exposed | Imported into a Client Component | Use import 'server-only' |
| The page loads slowly | Sequential data fetching | Use Promise.all |
| The whole page waits for one query | No Suspense boundary | Wrap in <Suspense> |
| The cookies are empty | The build-time model | Use the request-time model |
| The static export fails | The dynamic page | Remove the output: 'export' |
Real-World Examples
1. The request-time data access
const note = await db.notes.findUnique({ where: { id } })
2. The cookies
const cookieStore = await cookies()
const session = cookieStore.get('session')
3. The server-only guard
import 'server-only'
export const db = new Database(...)
4. The parallel fetching
const [note, comments] = await Promise.all([getNote(id), getComments(id)])
5. The Suspense streaming
<Suspense fallback={<Skeleton />}><Content /></Suspense>
6. The loading.tsx
// app/notes/[id]/loading.tsx
export default function Loading() { return <Skeleton /> }
7. The connection marker
import { connection } from 'next/server'
await connection() // Mark the read as request-time
8. The client-safe data
// Map to the client-safe shape before passing
const safeUser = { id: user.id, name: user.name }
9. The error boundary
// app/notes/[id]/error.tsx
'use client'
export default function Error({ error, reset }) { ... }
10. The dynamic rendering
// The cookies() call opts the route into the dynamic rendering
const cookieStore = await cookies()
Visual
The Request-Time Flow
┌──────────────────────────────────────────────────────────────┐
│ THE SERVER COMPONENT RUNS PER REQUEST │
│ │
│ Request arrives │
│ │ │
│ ▼ │
│ Server Component renders │
│ ├── reads the cookies │
│ ├── queries the database │
│ ├── renders the data │
│ └── produces the RSC Payload │
│ │ │
│ ▼ │
│ The client receives the rendered output │
│ The database client and the secret never reach the client │
└──────────────────────────────────────────────────────────────┘
The Parallel vs Sequential
┌──────────────────────────────────────────────────────────────┐
│ THE SEQUENTIAL AWAIT IS THE WATERFALL │
│ │
│ ❌ Sequential: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const note = await getNote() ← 100ms │ │
│ │ const comments = await getComments() ← 100ms │ │
│ │ Total: 200ms │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ✅ Parallel: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ const [note, comments] = await Promise.all([ │ │
│ │ getNote(), ← 100ms │ │
│ │ getComments(), ← 100ms │ │
│ │ ]) │ │
│ │ Total: 100ms │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
The server-only Guard
┌──────────────────────────────────────────────────────────────┐
│ THE server-only PACKAGE PREVENTS THE ACCIDENTAL IMPORT │
│ │
│ lib/db.ts │
│ ├── import 'server-only' │
│ └── export const db = ... │
│ │
│ If a Client Component imports lib/db.ts: │
│ └── The build fails with an error │
│ │
│ The secret never reaches the browser. │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Request-time model | The Server Components run on the web server per request |
| Data access | The database, the ORM, the secrets directly |
| API layer | Not required |
| Cookies/headers | Available in the request-time component |
| Streaming | <Suspense> boundary |
| Parallel fetching | Promise.all |
| Server-only guard | import 'server-only' |
| Build-time vs request-time | The static content vs the per-request data |
Key takeaways:
- The request-time model runs the Server Components on the web server for each request. The component can read the cookies, the headers, and the live database state, and the rendered output is sent to the client . The database client and the secrets never reach the browser.
- The direct data access eliminates the API layer. The Server Component imports the database client and queries the data directly, without the API route in between . The client receives only the rendered output, not the query logic or the credentials.
- The
server-onlypackage is the build-time guard. Theimport 'server-only'statement prevents the module from being included in the client bundle. If a Client Component tries to import it, the build fails . - The parallel fetching avoids the waterfall. The
Promise.allcall starts the multiple requests at the same time, and theSuspenseboundary streams the content as it resolves . - The request-time model is for the personalized content. The build-time model is for the static content, and the request-time model is for the user session, the live database, and the cookies . The same component type runs in either environment.
- The
connection()function marks a read as request-time. It opts the route into the dynamic rendering, which is the mechanism for the pages that depend on the incoming request .
Remember: The request-time model is the RSC on the web server. The Server Component reads the data directly, without the API layer, and the client receives only the rendered output. The server-only guard prevents the secret leak, the Promise.all avoids the waterfall, and the Suspense streams the content. The result is the dynamic, personalized page that is rendered on the server with the full data access, and the client that receives the HTML without the database client or the query logic.
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!