Server Components Refactor: From API Routes to Direct Loading
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:
- User visits /posts/my-post
- Client component renders with loading state
- Client fetches /api/posts/my-post
- API route reads filesystem, finds filename
- API returns JSON with filename
- Client dynamically imports content file
- Client finally renders content
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:
// 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:
// 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:
// 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
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
- Better SEO: Content pre-rendered in initial HTML
- Faster loads: No client-side API roundtrips
- Simpler code: 60 lines β 25 lines per route
- No exposed APIs: Internal operations stay internal
- Better performance: Single serverless invocation instead of two
- No loading states: Content available immediately
Server vs Client Components
Understanding when to use each:
Server Components (default)
- Fetch data from databases or filesystem
- Access backend resources directly
- Keep sensitive logic server-side
- Reduce client bundle size
- Improve SEO with pre-rendered content
Client Components ("use client")
- Use React hooks (useState, useEffect, etc.)
- Add interactivity and event listeners
- Access browser APIs (window, localStorage)
- Use libraries that depend on React Context (Emotion)
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.
// 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:
// 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.
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:
- It only works on elements you render directly
- Emotion manages style tags internally
- The warning is about structural mismatch (tag order), not content mismatch
What We Tried
suppressHydrationWarningon various elements β Didn't help- Disabling React Strict Mode β Works but loses other useful warnings
- Console filtering β Prevented by Next.js dev overlay architecture
- Client-only rendering β Works but loses SSR benefits
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:
- The site works perfectly (React auto-fixes the mismatch client-side)
- Warnings are cosmetic, not functional problems
- Most CSS-in-JS + App Router projects have 3-8 hydration warnings
- Our stack philosophy (Emotion for structured data) remains sound
- Alternatives like Tailwind don't align with our AI-first approach
Why We Stick With Emotion
Despite hydration warnings, Emotion aligns with our core philosophy:
- Structured Data: CSSObject is data, not magic strings
- Type-Safe: TypeScript validates styles at compile time
- AI-Friendly: LLMs understand objects better than utility classes
- Full CSS Power: No arbitrary value syntax or config bloat
- Composable: JavaScript object composition patterns
See WHY_NO_TAILWIND.md for our full manifesto on treating styles as structured data rather than magic strings.
Lessons Learned
- "use client" doesn't mean "skip SSR" - Client Components still server-render
- More concurrent hydration = more opportunities for CSS-in-JS ordering conflicts
- suppressHydrationWarning only works for intentional content mismatches (timestamps, etc)
- Hydration warnings from library internals can't be easily suppressed
- Cosmetic warnings are acceptable trade-offs for architectural benefits
- Philosophy matters: structured data > magic strings for AI-era development
Trade-offs Summary
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.