DEV Community

Cover image for Next.js Server Components: From 226ms to 23ms in Production
Adam Pitera
Adam Pitera

Posted on

Next.js Server Components: From 226ms to 23ms in Production

A practical migration guide for moving Next.js applications from client-side data fetching to server components with caching.

Quick Start

  1. Find a page using useEffect for data fetching
  2. Remove "use client"
  3. Make component async
  4. Wrap fetch in unstable_cache
  5. Test with DevTools (look for cache headers)

Details below, or jump to Real Example.

The Problem

Most Next.js apps default to client-side data fetching:

"use client";

export default function DashboardPage() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch('/api/dashboard')  // Runs on every page visit - no caching!
      .then(res => res.json())
      .then(setData)
      .finally(() => setLoading(false));
  }, []);

  if (loading) return <LoadingSpinner />;
  return <Dashboard data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Issues: No caching, loading spinners everywhere, large JavaScript bundles, poor SEO.

The Solution

Server components with caching:

import { unstable_cache } from 'next/cache';

const getCachedDashboard = unstable_cache(
  async () => {
    const res = await fetch('/api/dashboard');  // Cached for 60s - subsequent visits are instant!
    return res.json();
  },
  ['dashboard'],
  { revalidate: 60 }
);

export default async function DashboardPage() {
  const data = await getCachedDashboard();
  return <Dashboard data={data} />;
}
Enter fullscreen mode Exit fullscreen mode

Results: First load 226ms → cached loads 23-38ms (83% faster), no loading states, smaller bundles.

Note: unstable_cache API may change in future Next.js releases. Monitor release notes when upgrading.

Migration Strategy

When to Use Each Type

Component Decision Tree:
├─ Uses onClick/onChange? → Client Component
├─ Uses browser APIs? → Client Component  
├─ Needs useState/useEffect? → Client Component
├─ Real-time updates? → Client Component
├─ Only displays data? → Server Component
└─ Fetches data? → Server Component + Cache
Enter fullscreen mode Exit fullscreen mode

Migration Steps

  1. Remove "use client" directive
  2. Convert to async function
  3. Move data fetching into component
  4. Add caching with appropriate duration
  5. Extract interactive elements to client components

Real Example

// Before: Everything in one client component
"use client";
function ProductPage({ id }) {
  const [product, setProduct] = useState(null);
  const [quantity, setQuantity] = useState(1);

  useEffect(() => {
    fetchProduct(id).then(setProduct);  // Fetches on every visit - no caching!
  }, [id]);

  return (
    <div>
      <h1>{product?.name}</h1>
      <p>${product?.price}</p>
      <input 
        type="number" 
        value={quantity} 
        onChange={(e) => setQuantity(e.target.value)}
      />
      <button onClick={() => addToCart(id, quantity)}>
        Add to Cart
      </button>
    </div>
  );
}

// After: Server component with client island
// app/product/[id]/page.tsx
const getCachedProduct = unstable_cache(
  (id) => fetchProduct(id),  // Cached for 5 minutes - fast repeat visits!
  (id) => ['product', id],
  { revalidate: 300 }
);

export default async function ProductPage({ params }) {
  const product = await getCachedProduct(params.id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>${product.price}</p>
      <AddToCartForm productId={params.id} />  {/* Only this part needs client */}
    </div>
  );
}

// components/add-to-cart-form.tsx
"use client";
export function AddToCartForm({ productId }) {
  const [quantity, setQuantity] = useState(1);

  return (
    <>
      <input 
        type="number" 
        value={quantity} 
        onChange={(e) => setQuantity(e.target.value)}
      />
      <button onClick={() => addToCart(productId, quantity)}>
        Add to Cart
      </button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Common Patterns

Dashboard with Charts

export default async function Dashboard() {
  const stats = await getCachedStats();

  return (
    <>
      <StatsGrid data={stats} />    {/* Server: displays data */}
      <InteractiveChart />          {/* Client: user can interact */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Form Pages

export default function ContactPage() {
  return (
    <>
      <PageHeader />      {/* Server: static content */}
      <ContactForm />     {/* Client: handles submission */}
      <Footer />          {/* Server: static content */}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Cache Invalidation

import { revalidateTag } from 'next/cache';

export async function updateData(formData) {
  await saveToDatabase(formData);
  revalidateTag('dashboard');  // Clear cache
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

export default async function Page() {
  try {
    const data = await getCachedData();
    return <DataDisplay data={data} />;
  } catch (error) {
    return <ErrorMessage />;  // No error boundaries needed
  }
}
Enter fullscreen mode Exit fullscreen mode

Cache Duration Guide

Content Type Cache Duration Example Use Case
User dashboard 30-60s Recent activity
Product catalog 5-60min Inventory updates
Blog posts Hours/days Rarely changes
User-specific 0-30s Personal data

Common Pitfalls

1. Functions Don't Serialize

// ❌ Functions can't pass from server to client
const config = {
  validate: (val) => val > 0
};

// ✅ Pass data instead
const config = {
  minValue: 0
};
Enter fullscreen mode Exit fullscreen mode

Rule: If JSON.stringify() can't handle it, neither can server components.

2. Don't Over-Split

// ❌ Too many tiny components
<ServerWrapper>
  <ServerTitle />
  <ServerPrice />
  <ClientButton />
</ServerWrapper>

// ✅ Logical grouping
<ProductCard>           {/* Server: all static parts */}
  <AddToCartButton />   {/* Client: just the button */}
</ProductCard>
Enter fullscreen mode Exit fullscreen mode

Rule: Extract client components only for event handlers or hooks.

3. Forms Need Client

// ❌ Server components can't handle events
export default async function Form() {
  return <form onSubmit={handleSubmit}>...

// ✅ Forms are always client components
"use client";
export default function Form() {
  return <form onSubmit={handleSubmit}>...
Enter fullscreen mode Exit fullscreen mode

Rule: If users can type or click, it's a client component.

Verification

Test your cache is working:

async function testCache(url) {
  for (let i = 0; i < 3; i++) {
    const start = Date.now();
    const res = await fetch(url);
    console.log(`Request ${i + 1}: ${Date.now() - start}ms - ${res.headers.get('x-nextjs-cache') || 'MISS'}`);
    await new Promise(r => setTimeout(r, 1000));
  }
}
Enter fullscreen mode Exit fullscreen mode

Expected: First ~200ms (MISS), then ~30ms (HIT).

Production Tips

API Stability

  • Pin Next.js version: "next": "14.2.3" (no ^)
  • Check release notes before upgrading
  • Test in staging first

Edge Runtime

  • Check database driver compatibility
  • No file system access
  • Some packages won't work

Performance Expectations

Results vary based on infrastructure and data source latency. Typical improvement: 50-90% for cached requests.

Summary

Use server components for:

  • Data displays and dashboards
  • Content that changes infrequently
  • Reducing bundle size

Keep client components for:

  • Forms and user input
  • Real-time features
  • Browser APIs

Start with one data-heavy page, measure the improvement, then expand.

Resources

Top comments (0)