| | |

React Server Components 04 🧬 The Request-Time Model — Accessing data layers directly per request

⏱️ Estimated read: 12 min | |

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 SourceBuild TimeRequest Time
Static MarkdownYesYes
User sessionNoYes
Live databaseNoYes
Cookies/headersNoYes
Static CMSYesYes

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

PropertyRequest Time
Server requiredYes
Per-request dataYes
Cookies/headersYes
Live databaseYes
Static exportNo
DeploymentNode.js server, serverless

Data Access

APIPurpose
cookies()Read the request cookies
headers()Read the request headers
db.query()Query the database directly
Promise.allParallel data fetching

Streaming

APIPurpose
<Suspense>Stream the content as it resolves
fallbackThe loading UI
loading.tsxThe route-level fallback

Security

GuardPurpose
import 'server-only'Prevent the client import
server-only packageBuild-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

PitfallWhy It HappensFix
The database secret is exposedImported into a Client ComponentUse import 'server-only'
The page loads slowlySequential data fetchingUse Promise.all
The whole page waits for one queryNo Suspense boundaryWrap in <Suspense>
The cookies are emptyThe build-time modelUse the request-time model
The static export failsThe dynamic pageRemove 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

ItemValue
Request-time modelThe Server Components run on the web server per request
Data accessThe database, the ORM, the secrets directly
API layerNot required
Cookies/headersAvailable in the request-time component
Streaming<Suspense> boundary
Parallel fetchingPromise.all
Server-only guardimport 'server-only'
Build-time vs request-timeThe 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-only package is the build-time guard. The import '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.all call starts the multiple requests at the same time, and the Suspense boundary 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.


Was this helpful?


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!