Back to Blog

Next.js Server Components: Patterns for Production

12 min read
By Hari Prakash Pokala
Next.jsReactServer ComponentsPerformanceTypeScript

Next.js Server Components represent a paradigm shift in how we build React applications. After migrating several production applications to the App Router, here are the patterns and practices that deliver real-world results.

Understanding Server Components

Server Components run exclusively on the server, fundamentally changing the React mental model and unlocking new capabilities.

Key Benefits

  1. Zero JavaScript Sent to Client: Server Components don't ship JavaScript
  2. Direct Backend Access: Query databases, read files, access APIs directly
  3. Improved Performance: Faster initial page loads, reduced bundle size
  4. Better SEO: Fully rendered HTML for search engines
  5. Automatic Code Splitting: Components are naturally split

Server vs Client Components

When to Use Server Components

Use Server Components for:

  • Data fetching from databases/APIs
  • Accessing backend resources
  • Sensitive logic (API keys, tokens)
  • Large dependencies (markdown, syntax highlighting)
  • Static content rendering
// Server Component - Direct database access
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.user.findUnique({ where: { id: userId } });

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

When to Use Client Components

Use Client Components for:

  • Event handlers (onClick, onChange)
  • React hooks (useState, useEffect, useContext)
  • Browser APIs (localStorage, window)
  • Third-party libraries requiring browser APIs
  • Interactive components
'use client';

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(count + 1)}>
      Count: {count}
    </button>
  );
}

Data Fetching Patterns

1. Component-Level Data Fetching

Fetch data where you need it:

// app/dashboard/page.tsx
async function Dashboard() {
  // Parallel data fetching
  const [user, stats, notifications] = await Promise.all([
    fetchUser(),
    fetchStats(),
    fetchNotifications()
  ]);

  return (
    <div>
      <UserHeader user={user} />
      <StatsGrid stats={stats} />
      <NotificationList notifications={notifications} />
    </div>
  );
}

2. Sequential Data Fetching

When data depends on previous results:

async function OrderDetails({ orderId }: { orderId: string }) {
  // Fetch order first
  const order = await fetchOrder(orderId);

  // Then fetch related data
  const customer = await fetchCustomer(order.customerId);
  const items = await fetchOrderItems(orderId);

  return (
    <div>
      <OrderHeader order={order} customer={customer} />
      <OrderItems items={items} />
    </div>
  );
}

3. Streaming with Suspense

Improve perceived performance:

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <Header />

      <Suspense fallback={<StatsSkeleton />}>
        <Stats />
      </Suspense>

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />
      </Suspense>

      <Footer />
    </div>
  );
}

async function Stats() {
  const stats = await fetchStats(); // This can be slow
  return <StatsGrid data={stats} />;
}

Composition Patterns

Server-Client Component Composition

// Server Component
async function ProductPage({ id }: { id: string }) {
  const product = await fetchProduct(id);

  return (
    <div>
      {/* Server Component */}
      <ProductHeader product={product} />

      {/* Client Component for interactivity */}
      <AddToCartButton productId={product.id} />

      {/* Server Component */}
      <ProductReviews productId={product.id} />
    </div>
  );
}

// Client Component
'use client';

function AddToCartButton({ productId }: { productId: string }) {
  const [adding, setAdding] = useState(false);

  const handleAdd = async () => {
    setAdding(true);
    await addToCart(productId);
    setAdding(false);
  };

  return (
    <button onClick={handleAdd} disabled={adding}>
      {adding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

Passing Server Components as Props

// Client Component accepting Server Components
'use client';

function Tabs({
  children
}: {
  children: React.ReactNode
}) {
  const [active, setActive] = useState(0);

  return (
    <div>
      <TabButtons active={active} onChange={setActive} />
      <TabContent>{children[active]}</TabContent>
    </div>
  );
}

// Usage in Server Component
async function Page() {
  const [users, posts] = await Promise.all([
    fetchUsers(),
    fetchPosts()
  ]);

  return (
    <Tabs>
      <UserList users={users} />
      <PostList posts={posts} />
    </Tabs>
  );
}

Performance Optimization

1. Selective Hydration

Minimize client-side JavaScript:

// app/article/page.tsx
async function ArticlePage({ id }: { id: string }) {
  const article = await fetchArticle(id);

  return (
    <div>
      {/* Server Component - No JS */}
      <ArticleHeader article={article} />

      {/* Server Component - No JS */}
      <ArticleContent content={article.content} />

      {/* Client Component - Only this needs JS */}
      <CommentSection articleId={id} />

      {/* Server Component - No JS */}
      <RelatedArticles articleId={id} />
    </div>
  );
}

2. Loading States

Implement proper loading UI:

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-12 bg-gray-200 rounded mb-4" />
      <div className="h-64 bg-gray-200 rounded" />
    </div>
  );
}

3. Error Boundaries

Handle errors gracefully:

// app/dashboard/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  );
}

Metadata & SEO

Dynamic Metadata

import { Metadata } from 'next';

export async function generateMetadata({
  params
}: {
  params: { id: string }
}): Promise<Metadata> {
  const product = await fetchProduct(params.id);

  return {
    title: product.name,
    description: product.description,
    openGraph: {
      title: product.name,
      description: product.description,
      images: [product.image],
    },
  };
}

Static Params for SSG

export async function generateStaticParams() {
  const products = await fetchAllProducts();

  return products.map((product) => ({
    id: product.id,
  }));
}

Real-World Patterns

1. Dashboard with Real-Time Data

// Server Component for initial data
async function Dashboard() {
  const initialData = await fetchDashboardData();

  return (
    <Suspense fallback={<DashboardSkeleton />}>
      <DashboardLayout>
        {/* Server rendered */}
        <StatsCards data={initialData.stats} />

        {/* Client component for real-time updates */}
        <LiveChart initialData={initialData.chartData} />

        {/* Server rendered */}
        <RecentActivity data={initialData.activity} />
      </DashboardLayout>
    </Suspense>
  );
}

// Client Component for real-time
'use client';

function LiveChart({ initialData }) {
  const [data, setData] = useState(initialData);

  useEffect(() => {
    const ws = new WebSocket('ws://api.example.com');
    ws.onmessage = (event) => {
      setData(JSON.parse(event.data));
    };
    return () => ws.close();
  }, []);

  return <Chart data={data} />;
}

2. Form with Server Actions

// Server Component
async function ContactForm() {
  async function submitForm(formData: FormData) {
    'use server';

    const name = formData.get('name');
    const email = formData.get('email');

    await saveContact({ name, email });
    revalidatePath('/contacts');
  }

  return (
    <form action={submitForm}>
      <input name="name" required />
      <input name="email" type="email" required />
      <button type="submit">Submit</button>
    </form>
  );
}

3. Authenticated Routes

import { redirect } from 'next/navigation';

async function ProtectedPage() {
  const session = await getServerSession();

  if (!session) {
    redirect('/login');
  }

  const userData = await fetchUserData(session.userId);

  return <UserDashboard data={userData} />;
}

Common Pitfalls & Solutions

❌ Pitfall 1: Prop Serialization

// ❌ Wrong - Can't pass functions or Date objects
<ClientComponent onSave={handleSave} date={new Date()} />

// ✅ Correct - Pass serializable data
<ClientComponent
  onSave={() => handleSave()}
  dateString={new Date().toISOString()}
/>

❌ Pitfall 2: Context in Server Components

// ❌ Wrong - Can't use Context in Server Components
export default function ServerComponent() {
  const theme = useContext(ThemeContext); // Error!
}

// ✅ Correct - Use Client Component wrapper
'use client';

export function ThemeWrapper({ children }) {
  return (
    <ThemeContext.Provider value={theme}>
      {children}
    </ThemeContext.Provider>
  );
}

❌ Pitfall 3: Importing Client-Only Libraries

// ❌ Wrong - Importing browser-only library in Server Component
import confetti from 'canvas-confetti';

// ✅ Correct - Use dynamic import in Client Component
'use client';

function Celebration() {
  const celebrate = async () => {
    const confetti = (await import('canvas-confetti')).default;
    confetti();
  };

  return <button onClick={celebrate}>Celebrate!</button>;
}

Migration Strategy

Step-by-Step Migration

  1. Start with leaves: Migrate leaf components first
  2. Move up the tree: Gradually migrate parent components
  3. Mark boundaries: Use 'use client' strategically
  4. Test thoroughly: Ensure functionality and performance
  5. Monitor metrics: Track bundle size and performance

Conclusion

Server Components are not just an optimization—they're a new way of thinking about React applications. The key is understanding when to use Server vs Client Components and composing them effectively.

Key Takeaways:

  • Server Components for data fetching and static content
  • Client Components for interactivity and browser APIs
  • Use Suspense for progressive loading
  • Compose Server and Client Components thoughtfully
  • Leverage Server Actions for mutations

Resources:

Ready to migrate? Start small, measure impact, and gradually adopt Server Components across your application.