SWR with React Server Components
Recipe
Fetch data in a Server Component and pass it to a Client Component as fallback data for SWR. This gives you instant server-rendered content with client-side revalidation — the best of both worlds.
// app/users/page.tsx (Server Component)
import { UserList } from "./user-list";
async function getUsers() {
const res = await fetch("https://api.example.com/users", {
next: { revalidate: 60 },
});
return res.json();
}
export default async function UsersPage() {
const users = await getUsers();
return <UserList fallbackData={users} />;
}// app/users/user-list.tsx (Client Component)
"use client";
import useSWR from "swr";
interface User {
id: string;
name: string;
}
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export function UserList({ fallbackData }: { fallbackData: User[] }) {
const { data: users } = useSWR<User[]>("/api/users", fetcher, {
fallbackData,
revalidateOnMount: true, // Re-fetch on mount to get fresh data
});
return (
<ul>
{users?.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}Working Example
// app/dashboard/page.tsx (Server Component)
import { SWRProvider } from "./swr-provider";
import { DashboardStats } from "./dashboard-stats";
import { RecentOrders } from "./recent-orders";
async function fetchDashboardData() {
const [stats, orders] = await Promise.all([
fetch("https://api.example.com/stats").then((r) => r.json()),
fetch("https://api.example.com/orders?limit=10").then((r) => r.json()),
]);
return { stats, orders };
}
export default async function DashboardPage() {
const { stats, orders } = await fetchDashboardData();
return (
<SWRProvider
fallback={{
"/api/stats": stats,
"/api/orders?limit=10": orders,
}}
>
<h1>Dashboard</h1>
<DashboardStats />
<RecentOrders />
</SWRProvider>
);
}// app/dashboard/swr-provider.tsx
"use client";
import { SWRConfig } from "swr";
export function SWRProvider({
children,
fallback,
}: {
children: React.ReactNode;
fallback: Record<string, any>;
}) {
return (
<SWRConfig
value={{
fallback,
fetcher: (url: string) => fetch(url).then((r) => r.json()),
}}
>
{children}
</SWRConfig>
);
}// app/dashboard/dashboard-stats.tsx
"use client";
import useSWR from "swr";
interface Stats {
revenue: number;
orders: number;
customers: number;
}
export function DashboardStats() {
// Data is instantly available from server fallback
const { data: stats } = useSWR<Stats>("/api/stats", {
refreshInterval: 30000, // Keep stats fresh
});
return (
<div className="grid grid-cols-3 gap-4">
<div>Revenue: ${stats?.revenue.toLocaleString()}</div>
<div>Orders: {stats?.orders}</div>
<div>Customers: {stats?.customers}</div>
</div>
);
}// app/dashboard/recent-orders.tsx
"use client";
import useSWR from "swr";
interface Order {
id: string;
customer: string;
total: number;
status: string;
}
export function RecentOrders() {
const { data: orders } = useSWR<Order[]>("/api/orders?limit=10");
return (
<table>
<thead>
<tr>
<th>Order</th>
<th>Customer</th>
<th>Total</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{orders?.map((order) => (
<tr key={order.id}>
<td>{order.id}</td>
<td>{order.customer}</td>
<td>${order.total}</td>
<td>{order.status}</td>
</tr>
))}
</tbody>
</table>
);
}Deep Dive
How It Works
- Server Components fetch data at request time (or at build time with static rendering) and pass it down as props or via
SWRConfig.fallback. SWRConfigfallbackis a map of{ [key]: data }that pre-populates the SWR cache on the client.- When a
useSWRhook mounts with matching fallback data, it renders immediately without a loading state. - SWR then revalidates in the background (unless
revalidateOnMount: false), ensuring the data stays fresh. - This pattern avoids hydration mismatches because the server-rendered HTML matches the initial client render.
- The
preloadAPI can also be used to start fetching before a component mounts.
Variations
Preloading in Server Component:
// app/products/[id]/page.tsx
import { preload } from "swr";
import { ProductDetail } from "./product-detail";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function ProductPage({ params }: { params: { id: string } }) {
// Start fetching immediately
preload(`/api/products/${params.id}`, fetcher);
return <ProductDetail id={params.id} />;
}Per-page fallback without global provider:
"use client";
import useSWR from "swr";
export function UserCard({ fallbackData }: { fallbackData: User }) {
const { data } = useSWR("/api/me", fetcher, {
fallbackData, // Per-hook fallback
revalidateOnMount: false, // Trust server data, skip initial revalidation
});
return <div>{data?.name}</div>;
}Streaming with Suspense:
// Server Component
import { Suspense } from "react";
export default function Page() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<StatsSkeleton />}>
<StatsLoader />
</Suspense>
</div>
);
}
async function StatsLoader() {
const stats = await fetch("https://api.example.com/stats").then((r) => r.json());
return <StatsClient fallbackData={stats} />;
}TypeScript Notes
- The
fallbackprop inSWRConfigis typed asRecord<string, any>. There is no compile-time check that keys match youruseSWRkeys. - For stronger typing, create a typed wrapper:
type FallbackMap = {
"/api/stats": Stats;
"/api/orders?limit=10": Order[];
};
function TypedSWRProvider<T extends Record<string, unknown>>({
fallback,
children,
}: {
fallback: T;
children: React.ReactNode;
}) {
return <SWRConfig value={{ fallback }}>{children}</SWRConfig>;
}Gotchas
- The
fallbackkeys must exactly match the keys used inuseSWR. A mismatch means the fallback data is silently ignored and a loading state appears. - Data serialized from Server Components must be JSON-safe. Dates, Maps, Sets, and class instances will not survive the server-to-client boundary.
revalidateOnMount: true(the default whenfallbackDatais provided and norevalidateIfStaleis set) causes an immediate re-fetch. Set it tofalseif the server data is fresh enough.- Do not use
useSWRdirectly in Server Components. SWR hooks are client-only. Server Components should usefetchdirectly or a server-side data layer. - When using
fallbackinSWRConfig, the data is available to all nesteduseSWRhooks. Be careful about key collisions across unrelated components.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| SWRConfig fallback | Pre-populated cache, instant render | Keys must match exactly, no type safety on fallback |
| fallbackData per hook | Explicit, component-level | Must thread props from server |
| Server-only fetch (no SWR) | Simplest, no client JS | No client-side revalidation |
| React Query hydration | Built-in dehydrate/hydrate | Different library, more setup |
FAQs
How does the SWRConfig fallback option work with Server Components?
The fallback prop is a { [key]: data } map that pre-populates the SWR cache on the client. When a useSWR hook mounts with a matching key, it renders immediately without a loading state, then revalidates in the background.
What is the difference between fallbackData per-hook and fallback in SWRConfig?
fallbackDatais set peruseSWRhook as a prop:useSWR(key, fetcher, { fallbackData }).fallbackinSWRConfigprovides data for all nested hooks via a key-value map.- Use
fallbackDatafor individual components; usefallbackfor page-wide pre-population.
Gotcha: What happens if the fallback key does not exactly match the useSWR key?
The fallback data is silently ignored. SWR shows a loading state instead of instant content. Always ensure the keys in your fallback map match the keys used in useSWR calls exactly.
Can I use useSWR directly inside a Server Component?
No. SWR hooks are client-only. Server Components should use fetch directly or a server-side data layer, then pass the data as props or via SWRConfig fallback to Client Components.
Does SWR re-fetch on mount when fallbackData is provided?
By default, yes (revalidateOnMount: true). This ensures the data stays fresh. Set revalidateOnMount: false if the server data is fresh enough and you want to skip the initial client-side fetch.
How do I avoid hydration mismatches when using SWR with Server Components?
Pass server-fetched data through SWRConfig fallback or fallbackData. This ensures the server-rendered HTML matches the initial client render, preventing hydration errors.
Gotcha: What types of data cannot survive the server-to-client boundary?
Data serialized from Server Components must be JSON-safe. Dates, Maps, Sets, and class instances will not survive the boundary. Convert them to plain objects or strings before passing.
How do I use the preload API to start fetching before a component mounts?
import { preload } from "swr";
const fetcher = (url: string) => fetch(url).then((r) => r.json());
export default function Page({ params }: { params: { id: string } }) {
preload(`/api/products/${params.id}`, fetcher);
return <ProductDetail id={params.id} />;
}How do I type the fallback prop in SWRConfig with TypeScript?
The fallback prop is typed as Record<string, any> -- there is no compile-time check that keys match useSWR keys. For stronger typing, create a typed wrapper:
type FallbackMap = {
"/api/stats": Stats;
"/api/orders?limit=10": Order[];
};
function TypedSWRProvider<T extends Record<string, unknown>>({
fallback,
children,
}: {
fallback: T;
children: React.ReactNode;
}) {
return <SWRConfig value={{ fallback }}>{children}</SWRConfig>;
}How do I combine Server Component streaming with SWR?
Use <Suspense> around an async Server Component that fetches data and passes it as fallbackData to a Client Component with useSWR. The server streams the HTML as data becomes available.
What happens with key collisions when using fallback across unrelated components?
All nested useSWR hooks sharing the same key receive the same fallback data. Be careful that unrelated components do not accidentally use the same key, which would cause them to share stale or incorrect data.