Skip to main content

React Server Components in Boilerplates 2026

·StarterPick Team
Share:

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 Performance Case for Server Components

The reason boilerplates have adopted RSC isn't philosophical — it's practical. Server Components address three real performance problems:

JavaScript bundle reduction: In a typical Pages Router SaaS app, the dashboard JS bundle includes every library used anywhere in the dashboard: chart libraries, date formatting utilities, markdown parsers, data table libraries. With Server Components, libraries used only in server-rendered components never ship to the browser. A 200KB chart library that just renders static data in a summary table? Gone from the client bundle. Modern SaaS boilerplates see 30-60% bundle size reductions when aggressively using Server Components for read-only UI.

Waterfall elimination: The classic Pages Router data fetching pattern was: render shell, useEffect triggers, fetch to API route, API route queries database, response returns, component re-renders with data. That's at minimum 2 round trips (client → server → database → server → client) before the user sees data. Server Components collapse this to one: server queries the database and returns HTML. For slow database queries, this difference is invisible — but for fast queries (under 50ms), the round-trip elimination is meaningful.

Predictable loading states: When data fetching lives in Server Components with Suspense boundaries, loading states become structural rather than conditional. You define the loading skeleton once per Suspense boundary, and it shows automatically during the data fetch. No more if (isLoading) return <Skeleton /> scattered through component code.


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

When to Choose Server vs Client

The decision tree that most boilerplates follow (explicitly or implicitly):

Use Server Components when the component:

  • Fetches data from a database or API
  • Renders static or read-only UI
  • Contains no user interaction (no onClick, onChange, etc.)
  • Accesses environment variables, file system, or server-only secrets
  • Uses heavy libraries only for rendering (markdown parsers, date libraries, code highlighters)

Use Client Components when the component:

  • Uses React hooks (useState, useEffect, useContext, useRef)
  • Responds to browser events (clicks, form inputs, keyboard shortcuts)
  • Uses browser-only APIs (window, localStorage, navigator)
  • Needs real-time data updates via WebSocket or SSE
  • Uses animation libraries that require the DOM

The practical heuristic: start everything as a Server Component and only add 'use client' when you need interactivity. Most dashboard UI is mostly server-renderable — data grids, stats cards, billing history, user settings displays. The interactive parts (search filters, dropdown menus, form inputs, chart tooltips) are a small fraction of the component tree.


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} />;
}

RSC Caching and Revalidation

Server Components in Next.js integrate with the fetch cache and unstable_cache for data caching. Most boilerplates use a mix of strategies:

// lib/queries.ts — Cached server-side queries
import { unstable_cache } from 'next/cache';

// Cache pricing data — revalidate once per hour
export const getPricingPlans = unstable_cache(
  async () => {
    return prisma.pricingPlan.findMany({ where: { active: true } });
  },
  ['pricing-plans'],
  { revalidate: 3600 } // 1 hour
);

// Cache user-specific data with user ID tag for targeted invalidation
export const getUserDashboard = unstable_cache(
  async (userId: string) => {
    return {
      stats: await getDashboardStats(userId),
      recentActivity: await getRecentActivity(userId, 5),
    };
  },
  ['user-dashboard'],
  {
    revalidate: 60, // 1 minute default
    tags: (userId) => [`user-${userId}`], // Targeted invalidation
  }
);
// Invalidate cache after mutations
import { revalidatePath, revalidateTag } from 'next/cache';

// In a Server Action that updates user data:
export async function updateUserProfile(data: ProfileData) {
  await prisma.user.update({ where: { id: data.userId }, data });

  // Invalidate all pages that show this user's data
  revalidateTag(`user-${data.userId}`);
  revalidatePath('/dashboard');
}

The caching model is one of the more complex aspects of Server Components — and where most boilerplates provide only basic examples. Understanding when data gets stale and how to invalidate it correctly is important for building production SaaS features like real-time dashboards or collaborative tools.


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>;
}

Server Actions: RSC's Mutation Companion

Server Components handle reading data from the server. Server Actions handle writing data to the server — form submissions, mutations, and button click handlers — without needing a separate API route.

// app/dashboard/tasks/page.tsx — Server Component with Server Action
export default async function TasksPage() {
  const tasks = await getTasks();

  // Server Action — runs on the server, no API route needed
  async function createTask(formData: FormData) {
    'use server'; // This directive marks it as a server action

    const title = formData.get('title') as string;
    if (!title?.trim()) return;

    const session = await getServerSession();
    await prisma.task.create({
      data: {
        title: title.trim(),
        userId: session!.user.id,
      },
    });

    revalidatePath('/dashboard/tasks'); // Refresh the server component
  }

  return (
    <div>
      <form action={createTask}>
        <input name="title" placeholder="New task..." required />
        <button type="submit">Add Task</button>
      </form>
      <ul>
        {tasks.map(task => <li key={task.id}>{task.title}</li>)}
      </ul>
    </div>
  );
}

The 'use server' directive makes the function a server-executed action. When the form submits, Next.js calls the server action directly — no API route, no fetch call, no JSON serialization. The revalidatePath() call after the mutation tells Next.js to re-render the affected Server Components, so the new task appears immediately.

Server Actions work in both Server Components (inline, as above) and Client Components (imported from a separate actions.ts file). For complex forms with validation feedback, the useFormState and useFormStatus hooks in Client Components give you optimistic updates and loading states while still keeping the mutation logic server-side.

Most boilerplates in 2026 use Server Actions for simple CRUD operations and reserve API routes for webhook endpoints (Stripe, OAuth callbacks) and complex mutations that need explicit HTTP status codes.


Boilerplate RSC Adoption

BoilerplateRSC UsageApp RouterServer ActionsSuspense
ShipFast✅ Extensive
Supastarter
Makerkit
T3 Stack✅ (community)UpdatingPartialPartial
Epic Stack❌ Remix (different model)N/A✅ (Remix)

Parallel Data Fetching Patterns

One of the most performance-critical patterns in Server Components is avoiding sequential database queries. When a page needs multiple independent pieces of data, fetching them sequentially is a common mistake:

// ❌ Sequential — total time = A + B + C
export default async function Dashboard() {
  const user = await getUser();        // 50ms
  const stats = await getStats();      // 200ms
  const activity = await getActivity();// 100ms
  // Total: ~350ms
}

// ✅ Parallel — total time = max(A, B, C)
export default async function Dashboard() {
  const [user, stats, activity] = await Promise.all([
    getUser(),        // 50ms
    getStats(),       // 200ms  ← bottleneck
    getActivity(),    // 100ms
  ]);
  // Total: ~200ms (2x faster)
}

When queries are genuinely dependent (you need the user ID before fetching their subscription), sequential is unavoidable. But most dashboard pages have independent data requirements — user profile, aggregate stats, recent records — that can safely be parallelized.

The Suspense streaming pattern extends this further: parallel components can start streaming their HTML to the browser as soon as each individual query completes, rather than waiting for all queries to finish before sending anything. A stats query that takes 500ms doesn't block a notifications panel that completes in 50ms:

// Each Suspense boundary streams independently
export default function Dashboard() {
  return (
    <div className="grid grid-cols-2 gap-6">
      <Suspense fallback={<StatsSkeleton />}>
        <SlowStatsPanel />     {/* Streams at ~500ms */}
      </Suspense>
      <Suspense fallback={<NotifSkeleton />}>
        <FastNotifPanel />     {/* Streams at ~50ms — shows first */}
      </Suspense>
    </div>
  );
}

From the user's perspective, the page starts showing content at 50ms and finishes loading at 500ms, rather than showing nothing until all 550ms have elapsed.


The Practical Migration Mindset

For developers coming from Pages Router, the hardest part of adopting Server Components isn't the API — it's the mental model reset. In Pages Router, components were always interactive; you added getServerSideProps or getStaticProps to inject server data. In App Router, components are always server by default; you add 'use client' to inject interactivity.

The common mistake is over-using 'use client'. Teams coming from Pages Router often mark entire feature directories as client components because "it's safer" or because they encountered an error and the fix was to add 'use client'. This works, but it sacrifices the bundle size and performance benefits that motivated the architecture change.

The discipline: when you need to add 'use client', add it to the smallest possible component. If a 200-line page component needs one button to have a click handler, extract the button to a small client component rather than marking the whole page client. This preserves the data-fetching and rendering benefits of RSC for all the non-interactive parts.


RSC and Third-Party Libraries

Not every npm package works in Server Components. Libraries designed for browser environments — those that import window, document, or browser-specific APIs — will throw errors when imported in a Server Component. The 'use client' boundary is the solution, but knowing when you've crossed it requires understanding what each library does internally.

Libraries that work in Server Components: Prisma, Drizzle, Zod, date-fns, nodemailer, Stripe (server-side SDK), Resend, crypto utilities. These don't reference browser APIs and run fine on the server.

Libraries that require Client Components: Framer Motion, React Query, Radix UI (interactive parts), chart libraries (Recharts, Chart.js), drag-and-drop libraries, most animation libraries. These depend on DOM APIs and event handlers.

Libraries with split exports: Many major libraries now ship separate server and client exports. React Query has HydrationBoundary for server-side data prefetching alongside client-side cache. Radix UI has static display components and interactive trigger components. Always check the library's documentation for "Server Components support" before assuming it works everywhere.

When a third-party library causes a "You're importing a component that needs X" error in a Server Component, the fix is usually to create a thin Client Component wrapper that imports just the library component:

// components/ui/motion-div.tsx
'use client';
import { motion } from 'framer-motion';
export const MotionDiv = motion.div; // Re-export for use in any component tree

For boilerplates that have fully adopted the App Router architecture including Server Components, Suspense streaming, and Server Actions, best boilerplates for multi-tenant SaaS covers how Makerkit and Supastarter use RSC for their org-scoped data fetching patterns. For adding real-time features that require Client Components alongside server-rendered layout, how to add real-time WebSockets to your boilerplate covers the hybrid pattern where server renders the shell and client handles live updates. For understanding when feature flags need client-side evaluation vs server-side, how to add feature flags to your SaaS starter covers both the server component and client component patterns.


Methodology

Server Component patterns based on the Next.js App Router documentation and the React Server Components RFC. Performance benchmarks derived from Vercel's internal Next.js metrics and community case studies. Boilerplate RSC adoption table reflects direct code review of ShipFast, Makerkit, Supastarter, T3 Stack, and Epic Stack as of Q1 2026.

Find App Router / Server Component boilerplates on StarterPick.

The SaaS Boilerplate Matrix (Free PDF)

20+ SaaS starters compared: pricing, tech stack, auth, payments, and what you actually ship with. Updated monthly. Used by 150+ founders.

Join 150+ SaaS founders. Unsubscribe in one click.