Server Components Refactor: From API Routes to Direct Loading

By Claude Sonnet 4.5Β Β Β·Β  January 19, 2026
docsProject: jaygriff
🏷️ Tags:nextjsserver-componentsrefactoringperformance

How we refactored content loading from client-side API fetches to server-side direct imports, eliminating API routes and improving performance.

The Problem

Originally, our dynamic content routes ([slug] pages) were client components that fetched data from API routes to determine which content file to load. This created an unnecessarily complex flow:

This meant two serverless function invocations, client-side loading states, exposed internal APIs, and poor SEO (no content in initial HTML).

The Solution

We eliminated API routes entirely by using Next.js Server Components to load content directly on the server:

1. Created Content Loader Utility

A single utility function handles slug-to-content mapping server-side:

.ts
// src/lib/content-loader.ts
import { getAllPosts, getAllDocs } from './posts';
import type { PostMeta } from '@/types/post';

export type ContentType = 'post' | 'doc';

export interface LoadedContent {
  Component: React.ComponentType;
  metadata: PostMeta;
  filename: string;
}

export async function loadContentBySlug(
  slug: string,
  type: ContentType
): Promise<LoadedContent | null> {
  const allContent = type === 'post' ? await getAllPosts() : await getAllDocs();
  const content = allContent.find((item) => item.metadata.slug === slug);
  
  if (!content) return null;
  
  const module = await import(`@content/tsx/${content.filename}`);
  
  return {
    Component: module.default,
    metadata: module.metadata,
    filename: content.filename,
  };
}

2. Refactored Pages to Server Components

Pages became async Server Components that load content directly:

.ts
// Before: Client Component (~60 lines)
"use client";
import { useState, useEffect } from "react";

export default function Page({ params }) {
  const [component, setComponent] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    fetch(`/api/posts/${slug}`)
      .then(res => res.json())
      .then(data => import(`@content/tsx/${data.filename}`))
      .then(module => setComponent(module.default));
  }, [slug]);
  
  if (loading) return null;
  return <Container>...</Container>;
}

// After: Server Component (~25 lines)
import { loadContentBySlug } from "@/lib/content-loader";

export default async function Page({ params }) {
  const { slug } = await params;
  const content = await loadContentBySlug(slug, 'post');
  
  if (!content) notFound();
  
  const { Component, metadata } = content;
  
  return (
    <Container size="sm">
      <article>
        <ContentHeader metadata={metadata} />
        <ContentWrapper>
          <Component />
        </ContentWrapper>
      </article>
    </Container>
  );
}

3. Established Client Boundaries

Since content files use Emotion (which requires React Context), we created a wrapper to establish the client boundary:

.ts
// src/components/ContentWrapper.tsx
"use client";

export function ContentWrapper({ children }) {
  return <>{children}</>;
}

This single client component wraps all dynamic content, giving it access to Emotion's context without making the entire page client-side.

4. Deleted API Routes

With direct server-side loading, we deleted the entire /app/api directory. No more exposed endpoints for internal operations.

Architecture Diagram

.txt
Before:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Browser   β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
       β”‚ GET /posts/my-post
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client Component       β”‚
β”‚  - useState/useEffect   β”‚
β”‚  - Loading state        β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ fetch('/api/posts/my-post')
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  API Route              β”‚
β”‚  - Read filesystem      β”‚
β”‚  - Find file            β”‚
β”‚  - Return JSON          β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ { filename: 'my-post.tsx' }
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Dynamic Import         β”‚
β”‚  - Load component       β”‚
β”‚  - Render               β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

After:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚   Browser   β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
       β”‚ GET /posts/my-post
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Server Component       β”‚
β”‚  - loadContentBySlug()  β”‚
β”‚  - Direct import        β”‚
β”‚  - Pre-render HTML      β”‚
β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
       β”‚ Pre-rendered HTML
       β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Client Boundary        β”‚
β”‚  - ContentWrapper       β”‚
β”‚  - Emotion context      β”‚
β”‚  - Hydrate content      β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Benefits

Server vs Client Components

Understanding when to use each:

Server Components (default)

Client Components ("use client")

Pattern: Composition

The key pattern is composition: Server Components can render Client Components, passing data down as props. This lets you keep most of your app server-rendered while adding client interactivity only where needed.

.ts
// Server Component
export default async function Page() {
  const data = await loadDataFromDatabase();
  
  return (
    <Container> {/* Server-rendered */}
      <ClientInteractiveWidget data={data} /> {/* Client-rendered */}
    </Container>
  );
}

Build-Time Optimization

We added generateStaticParams to pre-render all content pages at build time:

.ts
// In content-loader.ts
export async function getAllPostSlugs() {
  const posts = await getAllPosts();
  return posts.map(post => ({ slug: post.metadata.slug }));
}

// In posts/[slug]/page.tsx
export async function generateStaticParams() {
  return await getAllPostSlugs();
}

Now all ~15 content pages are pre-rendered as static HTML during npm run build. Users get instant page loads from the CDN instead of waiting for serverless functions.

The Hydration Challenge

After the refactor, we encountered hydration warnings from Emotion. Understanding this issue taught us important lessons about Server Components and CSS-in-JS.

What Happened

Client Components (marked with "use client") still render on the server first, then hydrate on the client. Before the refactor, content loaded asynchronously after hydration. After the refactor, both NavBar and content components hydrate simultaneously.

.txt
Before Refactor:
1. Server renders NavBar β†’ HTML with Emotion styles
2. Client hydrates NavBar
3. Content loads later (client-only fetch + import)
4. Emotion handles them separately βœ…

After Refactor:
1. Server renders NavBar + Content β†’ HTML with Emotion styles
2. Client hydrates both simultaneously
3. Emotion re-injects styles for both at once
4. Style tag injection order differs server vs client ❌

Why It's Hard to Fix

The hydration mismatch happens inside Emotion's internal style tag injection in the <head>, not in your component markup. You can't use suppressHydrationWarning because:

What We Tried

The Real Fix (Not Worth It)

Properly fixing it requires Emotion's advanced SSR cache setup to ensure identical style injection order server and client. This is complex, error-prone, and not worth the effort for cosmetic warnings.

The Pragmatic Decision

We accepted the warnings because:

Why We Stick With Emotion

Despite hydration warnings, Emotion aligns with our core philosophy:

See WHY_NO_TAILWIND.md for our full manifesto on treating styles as structured data rather than magic strings.

Lessons Learned

Trade-offs Summary

.txt
What We Gained:
βœ… 60β†’25 lines per route (simpler code)
βœ… Single serverless invocation (faster, cheaper)
βœ… Pre-rendered static HTML (better SEO, instant loads)
βœ… No exposed API routes (better security)
βœ… No loading states (better UX)
βœ… Build-time optimization (all pages pre-rendered)

What We Lost:
⚠️  1-3 hydration warnings in console (cosmetic only)

Verdict: Overwhelmingly positive. 10/10 would refactor again.

Next Steps

This refactor sets us up to add markdown file support, since we can now load and transform files server-side before rendering. The content-loader utility can be extended to handle both TSX and MD files, parsing frontmatter and rendering markdown on the server.