JSON in React Server Components: Fetching, Serialization Boundary, and Streaming
Last updated:
React Server Components (RSCs) reshaped how JSON moves through a React app. An async Server Component can call fetch() directly in its body, parse the response with .json(), and hand the result straight to JSX — no useEffect, no loading state, no API key reaching the browser. The result travels to the client as part of the RSC payload, which is a superset of JSON: plain objects, arrays, primitives, JSX, and Promises survive; Date, Map, Set, class instances, and functions do not. This guide covers the mental model, the practical fetch patterns, the serialization boundary that trips most people up, streaming with Suspense, Server Actions returning JSON, and Next.js 16's tag-based cache (cacheTag, cacheLife, updateTag).
Debugging weird JSON returning from a Server Component fetch? Paste the raw response into Jsonic's JSON Validator — it flags syntax errors with exact line numbers so you can rule out the upstream API before chasing RSC-specific issues.
Validate JSON responseWhat 'server component' actually means for JSON
A React Server Component is a component that renders on the server and never re-renders on the client. Its code does not ship in the browser bundle, its closures do not exist on the client, and its return value travels to the browser as a serialized payload — not as HTML alone, but as a streaming format React decodes into the DOM. That payload is where the JSON story starts.
For JSON specifically, this changes three things. First, you can fetch JSON in the component body itself, with secrets and API keys safely on the server. Second, you can importJSON files as ES modules — they get bundled into the server output and parsed once, not shipped to the client. Third, the server-to-client step is a serialization boundary: the data you pass to a Client Component goes through React's RSC serializer, which has its own allowlist of types.
The mental model: treat a Server Component as the data layer and a Client Component as the interaction layer. The Server Component fetches, parses, normalizes, and hands a clean JSON-shaped object to the client. The client receives plain data and attaches behavior with hooks, event handlers, and state. JSON is the lingua franca of the boundary — anything richer needs explicit conversion or a serialization helper. See our JSON in React guide for the broader patterns; this page focuses on the server-component twist.
Fetching JSON in a Server Component: native fetch() + Next.js cache
The canonical pattern is an async function component that calls fetch and awaits the response. No useEffect, no state variable, no loading flag. Errors throw; React catches them at the nearest error.tsx boundary.
// app/users/page.tsx — Server Component (no 'use client')
type User = { id: string; name: string; email: string }
export default async function UsersPage() {
const res = await fetch('https://api.example.com/users', {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
})
if (!res.ok) {
throw new Error(`Failed to load users: ${res.status}`)
}
const users: User[] = await res.json()
return (
<ul>
{users.map(u => (
<li key={u.id}>{u.name} — {u.email}</li>
))}
</ul>
)
}Three things to notice. process.env.API_KEY works in the function body and never reaches the browser bundle — the entire fetch call runs on the server. The await at the top level is legal because the component itself is async. And there is no client-side useState tracking loading or error — the framework derives both from the promise lifecycle.
Within a single render, identical fetch calls are automatically deduplicated by React, so two child Server Components hitting the same URL produce one network request. Across renders, caching is opt-in via the 'use cache' directive (covered in Section 7). The older fetch(url, { next: { revalidate: 60 } }) options still work in Next.js 16 but are deprecated — new code should use the directive.
The Server → Client serialization boundary (and what survives)
The moment a value crosses from a Server Component to a Client Component, React serializes it. The format is the RSC payload — a superset of JSON that adds support for JSX, Promises, and Server Action references. What you pass as a prop must be representable in that format, or the render fails with a serialization error.
| Type | Crosses boundary? | Notes |
|---|---|---|
| string, number, boolean, null | Yes | Plain JSON primitives |
| undefined | Yes | Preserved (unlike JSON.stringify which drops it) |
| Plain object, array | Yes | Recursive — children must also be serializable |
| JSX element | Yes | This is the headline RSC feature |
| Promise | Yes | Client unwraps with the use() hook |
Server Action (fn from 'use server' file) | Yes | Serializes as a stable callable reference |
| Date | Lossy | Becomes an ISO string on the client |
| Map, Set | No | Throws — convert to array first |
| Class instance | Lossy | Becomes plain object; methods and prototype lost |
| Regular function (not Server Action) | No | Throws — define on client or wrap as action |
| Circular reference | No | Throws — break the cycle |
| Symbol, BigInt | Partial | BigInt works in modern React; Symbol does not |
The rule of thumb: if it survives a JSON.stringify/JSON.parse round-trip, it survives the boundary. Two exceptions — JSX and Promises and Server Actions go through, even though they are not JSON. For everything else, normalize to JSON-compatible shapes on the server before passing them down.
Non-serializable props: Date, Map, Set, functions — the trap
The single most common RSC bug is passing a value that looks JSON-ish but breaks under serialization. The error messages are clear but the fixes vary by type. Here is the same payload shown the wrong way and the right way.
// ❌ Server Component — this will fail
// 'Functions cannot be passed directly to Client Components'
import { ProfileCard } from './profile-card' // a Client Component
export default async function Page() {
const user = await getUser()
return (
<ProfileCard
name={user.name}
joinedAt={user.joinedAt} // Date → becomes string on client
roles={new Set(user.roles)} // Set → throws
tags={new Map(Object.entries(user.tags))} // Map → throws
onSave={() => saveUser(user.id)} // function → throws
/>
)
}// ✅ Fixed — normalize before crossing the boundary
import { ProfileCard } from './profile-card'
import { saveUserAction } from './actions' // 'use server' file
export default async function Page() {
const user = await getUser()
return (
<ProfileCard
name={user.name}
joinedAtMs={user.joinedAt.getTime()} // number — clean
roles={Array.from(user.roles)} // array — clean
tags={Object.fromEntries(user.tags)} // plain object — clean
onSave={saveUserAction.bind(null, user.id)} // Server Action ref — clean
/>
)
}For projects with many rich types crossing the boundary, superjson is the standard escape hatch. It encodes Date, Map, Set, BigInt, and class instances into a JSON envelope with a metadata block, and decodes them back on the client. Use it at the action boundary (wrap return values) or at the prop boundary (wrap component props in a superjson container). For one-off cases, manual conversion is shorter and clearer.
Streaming JSON via Suspense and async components
The point of an async Server Component is not just convenience — it enables streaming. Wrap a slow Server Component in <Suspense> with a fallback, and React renders the fast parts of the page immediately, streams the shell to the browser, and fills in the slow part when its fetch resolves.
// app/dashboard/page.tsx
import { Suspense } from 'react'
async function SlowJsonList() {
// Imagine this is a 1.5s API call
const res = await fetch('https://api.example.com/heavy', { next: { revalidate: 60 } })
const data: { id: string; label: string }[] = await res.json()
return (
<ul>{data.map(d => <li key={d.id}>{d.label}</li>)}</ul>
)
}
export default function Dashboard() {
return (
<main>
<h1>Dashboard</h1>
<p>Static intro — ships immediately.</p>
<Suspense fallback={<p>Loading list…</p>}>
<SlowJsonList />
</Suspense>
</main>
)
}Each Suspense boundary is an independent stream chunk. If your page has three slow data dependencies, give each its own boundary — they download in parallel and the fastest one displays first. The browser does not wait for the slowest fetch before showing anything.
The complementary pattern is passing a Promise as a prop. The Server Component kicks off the fetch but does not await; it passes the unresolved promise to a Client Component, which calls the React 19 use() hook to suspend until it settles. That pattern is right when the client side needs interactivity on the resolved data (sorting, filtering, inline editing) — start the fetch on the server, but bind the rendering to a Client Component.
Server Actions returning JSON to client
Server Actions are functions defined in a file marked 'use server' (or inline with the directive) that the client can invoke across the network. They are the canonical way to handle mutations from a Client Component: form submits, button clicks, optimistic updates. The return value travels back through the same serializer as Server Component props, so the JSON allowlist applies.
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
type Result = { ok: true; user: { id: string; name: string } } | { ok: false; error: string }
export async function saveUser(formData: FormData): Promise<Result> {
const name = formData.get('name')?.toString() ?? ''
if (!name) return { ok: false, error: 'Name required' }
const user = await db.user.create({ data: { name } })
revalidatePath('/users')
// Return a plain JSON-shaped object — no Date, no Map
return {
ok: true,
user: { id: user.id, name: user.name },
}
}// app/new-user-form.tsx
'use client'
import { useActionState } from 'react'
import { saveUser } from './actions'
export function NewUserForm() {
const [state, action] = useActionState(saveUser, null)
return (
<form action={action}>
<input name="name" />
<button type="submit">Save</button>
{state?.ok === false && <p>{state.error}</p>}
{state?.ok === true && <p>Saved {state.user.name}</p>}
</form>
)
}The return value here is a discriminated union of two plain objects — the kind of JSON the serializer is happy with. If you tried to return { ok: true, user: dbUserInstance } with dbUserInstance being an ORM model with methods and a Date, the methods would be stripped and the date would arrive as a string. Convert at the action boundary, not in the calling component.
Tagged caching: cacheTag, cacheLife, updateTag in Next.js 16
Next.js 16 consolidated caching around the 'use cache' directive plus tag-based invalidation. A function (or a whole file or component) that starts with 'use cache' memoizes its return value; you set the lifetime with cacheLife and label entries with cacheTag so they can be invalidated together.
// app/lib/users.ts
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'
export async function getUser(id: string) {
'use cache'
cacheLife('hours') // built-in profile — see docs for the full list
cacheTag('user', `user:${id}`)
const res = await fetch(`https://api.example.com/users/${id}`)
return res.json() as Promise<{ id: string; name: string }>
}// app/actions.ts
'use server'
import { updateTag } from 'next/cache'
export async function renameUser(id: string, name: string) {
await db.user.update({ where: { id }, data: { name } })
// Bust every cache entry tagged for this user
updateTag(`user:${id}`)
}The pattern: cache reads at the data-access layer, attach a stable tag per logical entity, and call updateTag from any mutation that affects that entity. The cache is shared across requests, deduplicated across renders, and invalidated precisely — no full-route revalidation needed when one row changes.
The older fetch-level options (next.revalidate, next.tags, cache: 'force-cache') still work in Next.js 16 but are deprecated. They survive one more major version; new code should use the directive. For mixed code bases, the two systems coexist — pick one per file rather than per call.
Common errors: 'Cannot pass functions to Client Components', circular refs
The serialization errors all sound similar but each has a specific cause and fix. Here are the four you will hit most often.
| Error | Cause | Fix |
|---|---|---|
Functions cannot be passed directly to Client Components | Inline closure or imported function passed as a prop across the boundary | Move the function to a 'use server' file (becomes a Server Action) or define it inside the Client Component |
Only plain objects can be passed to Client Components | Class instance, Map, Set, or other rich type as a prop | Convert to plain object/array on the server, or wrap with superjson |
Converting circular structure to JSON | Object graph with a back-reference (e.g., parent ↔ child) | Strip the back-reference before passing; flatten to IDs and a lookup map |
Cannot read properties of undefined on the client after a fetch | Date arrived as ISO string and client code called .getTime() on it | Either coerce on the server (.getTime() → number prop) or call new Date(prop) on the client |
Reproduce the circular-reference one and you will see how the fix often clarifies the design:
// ❌ Circular: parent.children[0].parent === parent
const parent = { id: 'p1', children: [] as Child[] }
const child = { id: 'c1', parent }
parent.children.push(child)
// ✅ Flatten — IDs only, hydrate on the client
const flat = {
parent: { id: parent.id },
children: parent.children.map(c => ({ id: c.id, parentId: parent.id })),
}For deeper Next.js routing questions, see JSON in Next.js and Route Handlers. For TypeScript-level modeling of these payloads, see TypeScript JSON types. The ECMAScript import attributes syntax that pairs nicely with import data from './data.json' is covered in our Import attributes guide.
Key terms
- React Server Component (RSC)
- A component that renders on the server, never re-renders on the client, and ships its output as part of the RSC payload rather than the client JavaScript bundle. Can be
asyncandawaitdirectly. Default kind in the Next.js App Router unless a file declares'use client'. - RSC payload
- React's streaming wire format for Server Component output. A superset of JSON: adds JSX, Promises, and Server Action references. Decoded by the React runtime in the browser into a DOM tree.
- Serialization boundary
- The transition from a Server Component to a Client Component (the point where a
'use client'file is imported). Props crossing this boundary must be representable in the RSC payload format. - Server Action
- A function declared in (or imported from) a file with
'use server'at the top. The function executes on the server; the client receives a stable callable reference that triggers a network round-trip when invoked. See our Server Actions guide for the data-mutation patterns. - use() hook
- React 19 hook that unwraps a Promise inside a Client Component. Suspends rendering until the promise settles; integrates with the nearest
Suspenseboundary for the loading UI. - cacheTag / updateTag
- Next.js 16 cache primitives.
cacheTag(...tags)labels a cached value;updateTag(tag)invalidates every entry that carries that tag. Replaces the olderrevalidateTagpattern for new code. - superjson
- Third-party serializer that wraps JSON with a metadata block, preserving
Date,Map,Set,BigInt, and class instances across the RSC boundary. Optional — manual conversion is often simpler.
Frequently asked questions
Can I import JSON files in a Server Component?
Yes — and it is the most efficient option for static JSON that ships with your repo. A Server Component can do import data from "./data.json" exactly like any other module, and the file is read at build time (or first request), bundled into the server output, and never sent to the browser. The JSON parses once on the server and the resulting object becomes a regular JavaScript value that you can pass to other Server Components, render directly, or hand to a Client Component as a serializable prop. For static config, copy decks, route maps, and other repo-resident data, prefer module import over fetch — it skips the network entirely and the bundler can tree-shake unused fields. If you want explicit attestation that the file is JSON (rather than a JavaScript module the bundler is interpreting), pair the import with the new ECMAScript import attributes syntax: import data from "./data.json" with { type: "json" }.
What gets serialized when a Server Component passes data to a Client Component?
React serializes Server Component output and props using its own format (the RSC payload), which is a superset of JSON. Safe types: strings, numbers, booleans, null, plain objects, arrays, and JSX elements. React also serializes Promises (the client can await them with the use() hook) and ServerReferences (the closures behind Server Actions, which serialize as a stable ID the runtime can call back). What does not survive: class instances (they become plain objects, losing methods and prototype), functions defined in your code (the prop must come from a "use server" file to be callable), Date objects (they become ISO strings), Map and Set (they throw), Symbols, BigInt in some serializers, and circular references. Anything containing one of those values must be normalized to a plain JSON-compatible shape before it crosses the boundary, or you wrap your serializer with a library like superjson.
How do I send a Date object from server to client?
React serializes Date values to ISO 8601 strings as it crosses the Server-to-Client boundary, so the client receives a string instead of a Date instance. You have three options. (1) Accept the string — change the Client Component prop type to string and call new Date(prop) where you need a Date instance. This is the simplest and works fine for read-only display. (2) Pass a number — convert with .getTime() on the server and pass the millisecond timestamp; the client takes a number prop and rehydrates with new Date(prop). Numbers serialize cleanly and require no special handling. (3) Use superjson — wrap your serialization with the superjson library, which encodes Date, Map, Set, BigInt, and other rich types into a JSON envelope with a metadata block, then decodes them back to the original type on the client. Pick option 1 or 2 for simple cases; reach for superjson only when you have many rich types crossing the boundary.
Why am I getting 'Functions cannot be passed directly to Client Components'?
You tried to pass a function defined in a Server Component as a prop to a Client Component. React cannot serialize an arbitrary closure — the client has no way to invoke server-side code by reference. The fix depends on what you wanted. If the function needs to run on the server (mutate the database, send an email, hit a paid API), define it in a file with "use server" at the top — it becomes a Server Action, which serializes as a stable reference the client can call across the network. If the function needs to run on the client (handle a click, format a value, validate input), define it inside the Client Component itself, or import it from a regular module the client bundle can include. The mental model: data crosses the boundary as JSON, behavior crosses the boundary as an RPC handle (Server Action) or lives on whichever side actually executes it.
Should I parse JSON on the server or the client?
Parse on the server when possible. A Server Component that calls fetch and then .json() does the parse work once on the server, sends only the already-deserialized fields the client actually needs, and skips the bundle weight of JSON.parse from your client code. The client receives a pre-parsed JavaScript value through the RSC payload — no second parse step. Parse on the client when (a) the JSON is huge and the client only needs a subset that you cannot pre-filter on the server, (b) the response is user-generated and you want progressive parsing or streaming JSON parsers, or (c) you need the raw text for hash verification or signature checking. For the common case — display data on a page — server parse is faster, smaller, and uses less browser memory.
How does Next.js cache JSON fetched in a Server Component?
Next.js 16 routes all caching through the cache directive and tag-based invalidation. To cache a fetch result, wrap the function in "use cache" — Next.js memoizes the return value and serves it from cache on subsequent calls within the same lifetime. Set the lifetime with cacheLife("hours") or a custom profile, and attach tags with cacheTag("user", userId) so you can target invalidation. To bust the cache after a mutation, call updateTag("user", userId) from a Server Action — every entry tagged with that pair is invalidated and the next request re-fetches. Identical fetches within a single render are also automatically deduplicated by React itself, independent of the cache layer. This replaces the older fetch options (next.revalidate, next.tags, cache: "force-cache") that you may still see in stale tutorials — those still work for one more major version but are deprecated in favor of the cache directive.
Can a Server Action return a Map or Set?
Not directly. The return value of a Server Action travels back to the client through the same RSC serializer that handles Server Component props, so it has the same allowlist: plain objects, arrays, primitives, JSX, and Promises. Map and Set throw. The fix is the same: convert before returning. Array.from(map.entries()) becomes a tuple array the client can reconstruct with new Map(result), and Array.from(set) becomes an array the client can rebuild with new Set(result). For richer types throughout a code base, wrap your actions with superjson — it ships a serialize/deserialize pair you can apply at the action boundary so Map, Set, Date, BigInt, and class instances round-trip transparently. The same rule applies to error objects: throw a plain Error in a Server Action and the client receives a sanitized error with the message and digest, not the original instance.
How do I stream JSON responses with Suspense?
Wrap an async Server Component that awaits the slow data in a Suspense boundary with a fallback. React begins rendering the rest of the page immediately, sends the HTML for the fast parts, and streams in the JSON-backed component as soon as its fetch resolves. The browser sees the shell first and the data fills in without a full client navigation. The pattern is one Suspense per slow data dependency — multiple independent fetches each get their own boundary and arrive in whatever order they finish. You can also pass a Promise from a Server Component to a Client Component as a prop; the client unwraps it with the React 19 use() hook, which suspends until the promise settles. That pattern is useful when the client side needs interactivity on the data (sorting, filtering) but you still want the server to kick off the fetch.
Further reading and primary sources
- React Docs — Server Components — Official reference for what RSCs are, what they can do, and the serialization rules at the boundary
- React Docs — Server Functions (Server Actions) — 'use server' directive, function references, and the RPC model behind Server Actions
- Next.js Docs — Data Fetching — Patterns for fetching JSON in Server Components, Suspense streaming, and the App Router caching model
- Next.js Docs — 'use cache' directive — cacheLife, cacheTag, and updateTag — the Next.js 16 cache surface
- superjson — Drop-in JSON serializer that preserves Date, Map, Set, BigInt, and class instances — useful at the RSC boundary