React Server Components in Boilerplates
TL;DR
React Server Components (RSC) are now the default in Next.js App Router and all modern boilerplates. They eliminate the need for API routes for data fetching, reduce client JavaScript, and simplify auth patterns. The catch: the mental model shift is significant, and mixing server/client components has sharp edges that trip up teams.
What Server Components Actually Do
In the old model (Pages Router), every React component runs on the client:
// Pages Router — runs on client
// Every user downloads this JavaScript
// Every user runs this on their machine
function Dashboard({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/dashboard').then(r => r.json()).then(setData);
}, []);
return data ? <DashboardUI data={data} /> : <Loading />;
}
In the App Router with Server Components:
// App Router — runs on server
// This code NEVER ships to the browser
// The browser receives HTML and minimal JS for interactivity
async function Dashboard({ userId }: { userId: string }) {
// Direct database access — no API layer needed
const [user, stats, recentActivity] = await Promise.all([
prisma.user.findUnique({ where: { id: userId } }),
getDashboardStats(userId),
getRecentActivity(userId, 10),
]);
return (
<div>
<DashboardHeader user={user} />
<StatsGrid stats={stats} />
<ActivityFeed items={recentActivity} />
</div>
);
}
No useEffect. No loading state. No API route. The data is fetched on the server at request time.
The Server/Client Split
The most important concept: components are server by default, opt-in to client with 'use client'.
// app/dashboard/page.tsx — Server Component (default)
// Runs on server. Can access database, filesystem, env vars.
export default async function DashboardPage() {
const user = await getCurrentUser();
const data = await getDashboardData(user.id);
return (
<main>
<StaticHeader title="Dashboard" /> {/* Server — no interactivity needed */}
<DataDisplay data={data} /> {/* Server — just renders data */}
<InteractiveChart data={data} /> {/* Client — needs useState/animations */}
</main>
);
}
// components/interactive-chart.tsx — Client Component
'use client'; // <-- This directive marks it as client-side
import { useState, useEffect } from 'react';
export function InteractiveChart({ data }: { data: ChartData }) {
const [activePoint, setActivePoint] = useState<number | null>(null);
// This component IS shipped to the browser
// It receives `data` as props (serialized from server)
return (
<div>
<Chart data={data} onHover={setActivePoint} />
{activePoint !== null && <Tooltip index={activePoint} data={data} />}
</div>
);
}
The Key Rule
Server Components can import Client Components. Client Components CANNOT import Server Components.
// ✅ Correct: Server imports Client
// server-page.tsx (Server Component)
import { InteractiveButton } from './interactive-button'; // Client Component — OK
// ❌ Wrong: Client imports Server
// interactive-button.tsx (Client Component)
import { ServerDataFetcher } from './server-data'; // Server Component — FAILS
How Modern Boilerplates Use Server Components
Authentication Pattern
// app/(dashboard)/layout.tsx — Server Component
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const { userId } = await auth();
if (!userId) redirect('/sign-in');
// Fetch user data once here — all child pages inherit it via Context
const user = await prisma.user.findUnique({ where: { clerkId: userId } });
return (
<div className="flex h-screen">
<Sidebar user={user} />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}
Auth check happens on the server in the layout — no loading flicker, no redirect flash.
Data Co-Location
// Each page fetches exactly what it needs — no over-fetching
// app/(dashboard)/billing/page.tsx
export default async function BillingPage() {
const { userId } = await auth();
const subscription = await getSubscription(userId);
const invoices = await getInvoices(userId, 10);
return (
<>
<CurrentPlan subscription={subscription} />
<BillingHistory invoices={invoices} />
<ChangePlanButton /> {/* Client Component for interactivity */}
</>
);
}
Suspense and Streaming
// Parallel data fetching with independent loading states
import { Suspense } from 'react';
export default async function Dashboard() {
return (
<div>
{/* These load independently — slow one doesn't block fast one */}
<Suspense fallback={<StatsSkeleton />}>
<StatsSection /> {/* Slow query — shows skeleton while loading */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed /> {/* Fast query — shows immediately */}
</Suspense>
</div>
);
}
async function StatsSection() {
const stats = await getExpensiveStats(); // This can take time
return <StatsGrid stats={stats} />;
}
Common Pitfalls in Boilerplates
1. Serialization Errors
Server Components pass data to Client Components as props. Data must be serializable:
// ❌ Not serializable — Date objects become strings, Prisma instances lost
const user = await prisma.user.findUnique({ where: { id } });
return <ClientComponent user={user} />;
// user.createdAt (Date) will be serialized to string
// ✅ Explicit serialization
return <ClientComponent user={JSON.parse(JSON.stringify(user))} />;
// Or better: select only needed fields
const user = await prisma.user.findUnique({
where: { id },
select: { id: true, name: true, email: true, plan: true }
});
2. Context in Server Components
// ❌ Can't use Context in Server Components
import { useTheme } from 'next-themes'; // React hook — fails in Server Component
// ✅ Read from cookies/headers instead
import { cookies } from 'next/headers';
const theme = cookies().get('theme')?.value ?? 'light';
3. Event Handlers Require 'use client'
// ❌ onClick doesn't work in Server Components
export default function Page() {
return <button onClick={() => console.log('clicked')}>Click me</button>;
// Error: Event handlers cannot be passed to Client Component props.
}
// ✅ Extract interactive parts to Client Components
'use client';
export function ClickButton() {
return <button onClick={() => console.log('clicked')}>Click me</button>;
}
Boilerplate RSC Adoption
| Boilerplate | RSC Usage | App Router |
|---|---|---|
| ShipFast | ✅ Extensive | ✅ |
| Supastarter | ✅ | ✅ |
| Makerkit | ✅ | ✅ |
| T3 Stack | ✅ (community) | Updating |
| Epic Stack | ❌ Remix (different model) | N/A |
Find App Router / Server Component boilerplates on StarterPick.
Check out this boilerplate
View Next.js App Router on StarterPick →