For eight years, I built React applications the same way everyone else did: render everything on the client, fetch
data with useEffect, and watch the bundle size grow with every new feature. Then React Server Components arrived,
and I had to unlearn almost everything I thought I knew about frontend architecture.
The Mental Model Shift
The traditional React mental model is simple: components render on the client, and the server just delivers
JavaScript. Server Components flip this entirely. Now components can render on the server, access databases
directly, and send only the rendered output to the client. The JavaScript for those components never ships to the
browser.
This isn’t server-side rendering as we knew it. SSR renders your client components on the server for the initial page
load, then hydrates them on the client. Server Components are fundamentally different—they stay on the server
permanently. They never hydrate because there’s nothing to hydrate.
The implications are profound. A Server Component can import a 500KB charting library, render a complex
visualization, and send only the resulting HTML to the client. That 500KB never touches the browser. For
applications drowning in JavaScript, this changes everything.
Understanding the Execution Model
Let’s examine exactly how Server Components execute differently from traditional React components:
// ❌ Traditional Client Component - Everything runs in browser
'use client';
import { useState, useEffect } from 'react';
import { HeavyChartLibrary } from 'heavy-charts'; // 500KB shipped to browser!
export default function Dashboard() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// API call from browser - exposed endpoint, waterfall loading
fetch('/api/analytics')
.then(res => res.json())
.then(data => {
setData(data);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>; // Poor UX
return <HeavyChartLibrary data={data} />; // Heavy library in bundle
}
// ✅ React Server Component - Runs only on server
import { HeavyChartLibrary } from 'heavy-charts'; // Never ships to browser!
import { db } from '@/lib/database'; // Direct DB access
export default async function Dashboard() {
// Direct database query - no API endpoint needed
const data = await db.analytics.findMany({
where: { date: { gte: new Date('2025-01-01') } },
orderBy: { date: 'desc' }
});
// Library executes on server, only HTML sent to client
return <HeavyChartLibrary data={data} />;
}
// Result shipped to browser: ~5KB of HTML
// JavaScript shipped: 0KB from this component
The Bundle Size Impact
In a real-world migration I completed recently, we reduced our JavaScript bundle from 847KB to 243KB—a 71%
reduction—simply by moving data visualization components to the server. Page load time dropped from 3.2s to 0.9s on
3G connections.
// Bundle Analysis: Before vs After
// BEFORE (Client-Only):
// - react-chartjs-2: 127KB
// - date-fns: 67KB
// - lodash utilities: 48KB
// - PDF generation: 312KB
// - Excel export: 156KB
// Total: 710KB (of 847KB total)
// AFTER (Server Components):
// All heavy libraries moved to server
// Client receives pre-rendered HTML/SVG
// Total: 243KB (only interactive components)
The Component Boundary Problem
The hardest part of adopting Server Components isn’t the technology—it’s deciding where to draw the boundary between
server and client. Every interactive element needs to be a Client Component. Every component that uses useState,
useEffect, or browser APIs needs to be a Client Component. But every Client Component and its dependencies ship to
the browser.
I’ve found the best approach is to push interactivity to the leaves of your component tree. Keep your layouts, data
fetching, and business logic in Server Components. Only the actual interactive elements—buttons, forms, modals—need
to be Client Components. This minimizes the JavaScript you ship while maximizing the benefits of server rendering.
The “use client” directive marks the boundary. Once you add it to a file, that component and everything it imports
becomes client code. This makes the boundary explicit but also means you need to think carefully about your import
structure.
Component Composition Patterns
Here’s the wrong way and the right way to structure your components:
// ❌ WRONG: Making entire layout a Client Component
'use client';
import { useState } from 'react';
import { HeavyAnalytics } from './analytics'; // Unnecessarily shipped to client
import { HeavyDataTable } from './table'; // Unnecessarily shipped to client
import { SidebarNav } from './nav'; // Only this needs interactivity!
export default function Dashboard() {
const [selectedView, setSelectedView] = useState('overview');
return (
<div>
<SidebarNav selected={selectedView} onChange={setSelectedView} />
<HeavyAnalytics view={selectedView} />
<HeavyDataTable view={selectedView} />
</div>
);
}
// Problem: Everything becomes client code, huge bundle size
// ✅ CORRECT: Server Component with Client leaf nodes
import { HeavyAnalytics } from './analytics'; // Server Component
import { HeavyDataTable } from './table'; // Server Component
import { SidebarNav } from './nav-client'; // Client Component (leaf)
// Server Component (default)
export default function Dashboard({
searchParams
}: {
searchParams: { view?: string }
}) {
const view = searchParams.view || 'overview';
return (
<div>
{/* Client Component - only nav logic ships to browser */}
<SidebarNav selectedView={view} />
{/* Server Components - execute on server, HTML only to browser */}
<HeavyAnalytics view={view} />
<HeavyDataTable view={view} />
</div>
);
}
// nav-client.tsx - Small Client Component
'use client';
import { useRouter } from 'next/navigation';
export function SidebarNav({ selectedView }: { selectedView: string }) {
const router = useRouter();
return (
<nav>
{['overview', 'detailed', 'export'].map(view => (
<button
key={view}
onClick={() => router.push(`?view=${view}`)}
className={selectedView === view ? 'active' : ''}
>
{view}
</button>
))}
</nav>
);
}
// Only this small component + router logic ships to browser
The Composition Pattern: Passing Client Components as Props
One of the most powerful patterns is passing Client Components as children to Server Components:
// Server Component - can fetch data
export default async function Layout({ children }: { children: React.ReactNode }) {
const user = await getUser();
const notifications = await getNotifications(user.id);
return (
<div>
{/* Server-rendered header with data */}
<Header user={user} notifications={notifications} />
{/* Client components passed as children still work! */}
{children}
{/* Server-rendered footer */}
<Footer />
</div>
);
}
Data Fetching Revolution
In traditional React, data fetching is a dance of useEffect, loading states, and waterfall requests. Server
Components eliminate this entirely. You can fetch data directly in your component using async/await, and the
component waits for the data before rendering.
This sounds simple, but the implications are significant. No more loading spinners for initial data. No more
client-side caching libraries just to avoid refetching. No more exposing your API endpoints to the public. The data
fetching happens on the server, close to your database, with full access to your backend services.
The pattern I’ve adopted is to fetch data at the highest level possible in Server Components, then pass it down as
props. This keeps data fetching predictable and makes it easy to see where your data comes from. Client Components
receive data as props—they don’t fetch it themselves.
Parallel Data Fetching
One of the most powerful patterns is parallel data fetching at the component level:
// ❌ Waterfall - Each request waits for previous
export default async function Dashboard() {
const user = await getUser();
const posts = await getPosts(user.id); // Waits for user
const analytics = await getAnalytics(user.id); // Waits for posts
// Total time: 300ms + 200ms + 150ms = 650ms
}
// ✅ Parallel - All requests start simultaneously
export default async function Dashboard() {
// Start all fetches immediately
const userPromise = getUser();
const postsPromise = getPosts(); // Doesn't need to wait
const analyticsPromise = getAnalytics(); // Doesn't need to wait
// Wait for all to complete
const [user, posts, analytics] = await Promise.all([
userPromise,
postsPromise,
analyticsPromise
]);
// Total time: max(300ms, 200ms, 150ms) = 300ms
return (
<div>
<UserProfile user={user} />
<PostsList posts={posts} />
<AnalyticsDashboard analytics={analytics} />
</div>
);
}
Automatic Request Deduplication
React automatically deduplicates identical fetch requests within a single render:
// lib/data.ts - Fetching function
export async function getUser(id: string) {
const res = await fetch(`https://api.example.com/users/${id}`, {
next: { revalidate: 3600 } // Cache for 1 hour
});
return res.json();
}
// components/UserProfile.tsx
export async function UserProfile({ userId }: { userId: string }) {
const user = await getUser(userId); // Call 1
return <div>{user.name}</div>;
}
// components/UserStats.tsx
export async function UserStats({ userId }: { userId: string }) {
const user = await getUser(userId); // Call 2 - Same request!
return <div>Posts: {user.postCount}</div>;
}
// page.tsx
export default function Page() {
return (
<div>
<UserProfile userId="123" />
<UserStats userId="123" />
</div>
);
}
// React makes only ONE network request, both components get the data!
Direct Database Access Pattern
// app/dashboard/page.tsx
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
export default async function DashboardPage() {
// Authentication on the server
const user = await getCurrentUser();
if (!user) {
redirect('/login');
}
// Direct database query with authorization
const [projects, activities, teamMembers] = await Promise.all([
prisma.project.findMany({
where: {
teamId: user.teamId,
status: 'active'
},
include: {
tasks: {
where: { completed: false },
take: 5
}
}
}),
prisma.activity.findMany({
where: { teamId: user.teamId },
orderBy: { createdAt: 'desc' },
take: 10
}),
prisma.user.findMany({
where: { teamId: user.teamId },
select: { id: true, name: true, avatar: true }
})
]);
return (
<div>
<ProjectList projects={projects} />
<ActivityFeed activities={activities} />
<TeamMembers members={teamMembers} />
</div>
);
}
Streaming and Suspense
Server Components integrate deeply with React’s Suspense model. When a Server Component is fetching data, React can
stream the rest of the page to the browser while waiting. The user sees content immediately, and the slow parts fill
in as they become ready.
This is fundamentally different from traditional SSR, where the server waits for everything before sending anything.
With streaming, the time to first byte drops dramatically because you’re sending content as soon as it’s ready.
The practical implementation uses Suspense boundaries. Wrap slow components in Suspense with a fallback, and React
streams the fallback immediately while the slow component loads. When the data arrives, React streams the
replacement content. The user experience is dramatically better than watching a loading spinner.
Implementing Streaming with Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { Header } from './header';
import { QuickStats } from './quick-stats';
import { RecentActivity } from './recent-activity';
import { DetailedAnalytics } from './detailed-analytics';
import { Skeleton } from '@/components/skeleton';
export default function Dashboard() {
return (
<div>
{/* Fast: Renders immediately */}
<Header />
{/* Fast: Simple query, renders quickly */}
<Suspense fallback={<Skeleton className="h-32" />}>
<QuickStats />
</Suspense>
{/* Medium: Streams while analytics loads */}
<Suspense fallback={<Skeleton className="h-64" />}>
<RecentActivity />
</Suspense>
{/* Slow: Complex aggregations, streams last */}
<Suspense fallback={<AnalyticsSkeleton />}>
<DetailedAnalytics />
</Suspense>
</div>
);
}
// Each component loads independently
// User sees: Header → QuickStats → RecentActivity → DetailedAnalytics
// Total perceived time: ~500ms (instead of 2000ms waiting for everything)
Nested Suspense Boundaries
// components/detailed-analytics.tsx
import { Suspense } from 'react';
export async function DetailedAnalytics() {
// This component itself might load quickly
const config = await getAnalyticsConfig();
return (
<div>
<h2>Analytics Dashboard</h2>
{/* Each chart can load independently */}
<div className="grid grid-cols-2 gap-4">
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart config={config} />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<UserGrowthChart config={config} />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<ConversionChart config={config} />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<EngagementChart config={config} />
</Suspense>
</div>
</div>
);
}
The Caching Layers
Server Components introduce multiple caching layers that you need to understand. Request memoization deduplicates
identical requests within a single render. If three components fetch the same data, the request happens once. This
is automatic and requires no configuration.
The data cache persists across requests. When you fetch data, the result is cached and reused for subsequent
requests. This is where you control cache invalidation with revalidation strategies—time-based, on-demand, or
tag-based.
The full route cache stores the complete rendered output of static routes. For pages that don’t change often, the
entire HTML is cached and served without any server rendering. This gives you static site performance with dynamic
site flexibility.
Finally, the router cache on the client stores prefetched routes. When users navigate, the cached content appears
instantly. Understanding these layers and how they interact is crucial for building performant applications.
Cache Control Strategies
// 1. Time-based Revalidation
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 300 } // Revalidate every 5 minutes
});
return res.json();
}
// 2. On-Demand Revalidation by Path
import { revalidatePath } from 'next/cache';
export async function updateProduct(productId: string, data: any) {
await db.product.update({ where: { id: productId }, data });
// Revalidate specific pages
revalidatePath('/products');
revalidatePath(`/products/${productId}`);
}
// 3. On-Demand Revalidation by Tag
export async function getProducts() {
const res = await fetch('https://api.example.com/products', {
next: {
tags: ['products'] // Tag this request
}
});
return res.json();
}
import { revalidateTag } from 'next/cache';
export async function addProduct(data: any) {
await db.product.create({ data });
// Revalidate all requests tagged with 'products'
revalidateTag('products');
}
// 4. No Caching (Always Fresh)
export async function getLiveData() {
const res = await fetch('https://api.example.com/live', {
cache: 'no-store' // Fetch fresh data every time
});
return res.json();
}
// 5. Force Caching (Static)
export async function getStaticContent() {
const res = await fetch('https://api.example.com/content', {
cache: 'force-cache' // Cache indefinitely
});
return res.json();
}
Route Segment Config
// app/dashboard/page.tsx
// Configure caching at the route level
// Dynamic - No caching, always fresh
export const dynamic = 'force-dynamic';
// Static - Fully cached
export const dynamic = 'force-static';
// Revalidation interval
export const revalidate = 3600; // Revalidate every hour
// Control fetch cache
export const fetchCache = 'force-no-store'; // No fetch caching
export default async function Dashboard() {
// This route's behavior is controlled by exports above
const data = await getData();
return <div>{data}</div>;
}
Server Actions
Server Actions complete the picture by handling mutations. Instead of building API endpoints for every form
submission, you define a function with “use server” and call it directly from your Client Component. The function
runs on the server with full access to your backend.
This eliminates an entire category of boilerplate. No more API routes for simple mutations. No more manually handling
request/response serialization. The function signature is your API contract, and TypeScript ensures type safety
across the client-server boundary.
I’ve found Server Actions particularly powerful for form handling. The form can submit directly to a Server Action,
which validates the data, updates the database, and revalidates the cache—all in one function. Error handling and
optimistic updates integrate naturally with React’s patterns.
Server Actions in Practice
// app/actions/products.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { prisma } from '@/lib/prisma';
import { getCurrentUser } from '@/lib/auth';
// Define validation schema
const productSchema = z.object({
name: z.string().min(3).max(100),
description: z.string().min(10),
price: z.number().positive(),
categoryId: z.string().uuid()
});
// Server Action with full type safety
export async function createProduct(formData: FormData) {
// Server-side authentication
const user = await getCurrentUser();
if (!user) {
return { error: 'Unauthorized' };
}
// Extract and validate data
const rawData = {
name: formData.get('name'),
description: formData.get('description'),
price: Number(formData.get('price')),
categoryId: formData.get('categoryId')
};
const validation = productSchema.safeParse(rawData);
if (!validation.success) {
return {
error: 'Validation failed',
fieldErrors: validation.error.flatten()
};
}
try {
// Database mutation
const product = await prisma.product.create({
data: {
...validation.data,
userId: user.id
}
});
// Automatic cache invalidation
revalidatePath('/products');
revalidatePath('/dashboard');
return { success: true, product };
} catch (error) {
return { error: 'Failed to create product' };
}
}
// app/products/create-form.tsx
'use client';
import { useFormState, useFormStatus } from 'react-dom';
import { createProduct } from '@/app/actions/products';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Product'}
</button>
);
}
export function CreateProductForm() {
const [state, formAction] = useFormState(createProduct, null);
return (
<form action={formAction}>
<div>
<label htmlFor="name">Name</label>
<input type="text" name="name" required />
{state?.fieldErrors?.name && (
<span className="error">{state.fieldErrors.name}</span>
)}
</div>
<div>
<label htmlFor="description">Description</label>
<textarea name="description" required />
</div>
<div>
<label htmlFor="price">Price</label>
<input type="number" name="price" step="0.01" required />
</div>
{state?.error && (
<div className="error">{state.error}</div>
)}
<SubmitButton />
</form>
);
}
Optimistic Updates with Server Actions
// app/todos/todo-list.tsx
'use client';
import { useOptimistic } from 'react';
import { toggleTodo } from '@/app/actions/todos';
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodo] = useOptimistic(todos);
async function handleToggle(id: string) {
// Update UI immediately (optimistic)
setOptimisticTodo((prev) =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
// Send to server
await toggleTodo(id);
}
return (
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} className={todo.completed ? 'line-through' : ''}>
<button onClick={() => handleToggle(todo.id)}>
{todo.completed ? '✓' : '○'}
</button>
{todo.title}
</li>
))}
</ul>
);
}
Progressive Enhancement
Server Actions work even with JavaScript disabled:
// This form works WITHOUT JavaScript!
export function ContactForm() {
return (
<form action={submitContact}>
<input type="email" name="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
);
}
// With JavaScript: Enhanced UX with loading states, optimistic updates
// Without JavaScript: Still works, uses traditional form submission
The Migration Path
Migrating an existing application to Server Components requires patience. You can’t flip a switch and convert
everything. The recommended approach is incremental: start with new features using Server Components, then gradually
migrate existing code as you touch it.
The App Router in Next.js makes this practical. You can have pages using the old Pages Router alongside pages using
the new App Router. Migrate route by route, learning the patterns as you go. Don’t try to rewrite everything at
once.
For teams, I recommend starting with a single, well-understood feature. Build it with Server Components, learn the
patterns, document the gotchas, then expand. The mental model shift takes time, and it’s better to learn on a small
surface area.
Practical Migration Strategy
// Phase 1: Set up App Router alongside Pages Router
// next.config.js
module.exports = {
experimental: {
appDir: true // Enable App Router
}
}
// Directory structure:
// /pages ← Existing Pages Router
// /app ← New App Router (Server Components)
// /dashboard ← First migrated feature
// Phase 2: Create parallel route in App Router
// app/dashboard/page.tsx
export default async function DashboardPage() {
const data = await fetchDashboardData();
return <DashboardView data={data} />;
}
// Phase 3: Update internal links
// Before: <Link href="/dashboard"> (goes to pages/dashboard)
// After: <Link href="/dashboard"> (goes to app/dashboard)
// Phase 4: Delete old route when confident
// Delete: pages/dashboard/index.tsx
Common Migration Patterns
// Pattern 1: useEffect for data fetching → async Server Component
// BEFORE (Pages Router)
export default function Products() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/products')
.then(res => res.json())
.then(data => {
setProducts(data);
setLoading(false);
});
}, []);
if (loading) return <Loading />;
return <ProductList products={products} />;
}
// AFTER (App Router)
async function Products() {
const products = await prisma.product.findMany();
return <ProductList products={products} />;
}
// Pattern 2: getServerSideProps → Server Component
// BEFORE
export async function getServerSideProps() {
const products = await db.products.findMany();
return { props: { products } };
}
export default function Products({ products }) {
return <ProductList products={products} />;
}
// AFTER
async function Products() {
const products = await db.products.findMany();
return <ProductList products={products} />;
}
// Pattern 3: API Route + Client fetch → Server Action
// BEFORE
// pages/api/create-product.ts
export default async function handler(req, res) {
const product = await db.product.create({ data: req.body });
res.json(product);
}
// components/form.tsx
async function handleSubmit(data) {
await fetch('/api/create-product', {
method: 'POST',
body: JSON.stringify(data)
});
}
// AFTER
// app/actions.ts
'use server';
export async function createProduct(data: FormData) {
const product = await db.product.create({ data });
revalidatePath('/products');
return product;
}
What I Got Wrong
My biggest mistake was treating Server Components as an optimization technique. They’re not—they’re a new
architecture. Trying to retrofit them onto an existing mental model leads to frustration. You need to think
differently about where code runs and why.
I also underestimated the importance of the component boundary. Early on, I made too many things Client Components
because it was easier. The result was shipping more JavaScript than necessary. Taking time to properly structure the
component tree pays dividends.
Finally, I initially ignored the caching layers. Understanding when data is cached, how long it’s cached, and how to
invalidate it is essential. Without this understanding, you’ll either serve stale data or make unnecessary requests.
Lessons Learned the Hard Way
Mistake #1: Over-using ‘use client’
// ❌ I did this early on
'use client'; // Added to parent unnecessarily
import { HeavyComponent } from './heavy';
import { SmallButton } from './button';
export function Panel() {
const [open, setOpen] = useState(false);
return (
<div>
<SmallButton onClick={() => setOpen(!open)} />
{open && <HeavyComponent />} {/* This became client code! */}
</div>
);
}
// ✅ Better approach
// panel.tsx (Server Component)
import { TogglePanel } from './toggle-panel-client';
import { HeavyComponent } from './heavy'; // Stays on server
export function Panel() {
return (
<TogglePanel>
<HeavyComponent /> {/* Server Component */}
</TogglePanel>
);
}
// toggle-panel-client.tsx
'use client';
export function TogglePanel({ children }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>Toggle</button>
{open && children}
</div>
);
}
Mistake #2: Ignoring Cache Invalidation
// ❌ Cache never invalidates
export async function updateProduct(id: string, data: any) {
await db.product.update({ where: { id }, data });
// User sees stale data!
}
// ✅ Proper cache invalidation
export async function updateProduct(id: string, data: any) {
await db.product.update({ where: { id }, data });
revalidatePath('/products');
revalidatePath(`/products/${id}`);
revalidateTag('product-list');
}
Mistake #3: Not Using Suspense Effectively
// ❌ All or nothing loading
export default async function Page() {
const [fast, slow, verySlow] = await Promise.all([
getFastData(),
getSlowData(),
getVerySlowData()
]);
// User waits for slowest before seeing anything
}
// ✅ Progressive loading with Suspense
export default function Page() {
return (
<>
<Suspense fallback={<FastSkeleton />}>
<FastSection />
</Suspense>
<Suspense fallback={<SlowSkeleton />}>
<SlowSection />
</Suspense>
<Suspense fallback={<VerySlowSkeleton />}>
<VerySlowSection />
</Suspense>
</>
);
}
The Future of Frontend
React Server Components represent the biggest shift in React architecture since hooks. They blur the line between
frontend and backend in ways that simplify application development. The patterns are still evolving, but the
direction is clear: the server is becoming a first-class citizen in React applications.
For teams building new applications, Server Components should be the default. The performance benefits, the
simplified data fetching, and the reduced client-side complexity make them compelling. For existing applications,
the migration path is clear even if it takes time.
After two decades of building web applications, I’ve learned to recognize paradigm shifts. Server Components are one
of them. The sooner you invest in understanding them, the better prepared you’ll be for where frontend development
is heading.
The Road Ahead
What excites me most about Server Components isn’t just what they enable today, but where they’re heading:
- Partial Prerendering (PPR): Mixing static and dynamic content on the same page, with static
parts cached at the edge and dynamic parts streamed from the server - React Forget: Automatic memoization that makes React even faster with Server Components
- Enhanced Streaming: More granular control over what streams when, and better error boundaries
- Middleware Integration: Deeper integration between Server Components and edge middleware for
authentication, A/B testing, and personalization - Framework Convergence: Other frameworks adopting similar patterns, making Server Components a
cross-framework standard
Final Thoughts
The biggest mental shift required for Server Components is this: stop thinking of React as a client-side
framework. React is now a full-stack framework that happens to render UI. Components are no longer just
UI elements—they’re architectural boundaries between server and client, between data and presentation, between logic
and interaction.
This is uncomfortable if you’ve spent years mastering client-side React. But it’s also liberating. You can build
features faster, ship less JavaScript, and create better user experiences. The learning curve is real, but the
payoff is worth it.
Welcome to the server-first revolution. Your frontend will never be the same.
Discover more from C4: Container, Code, Cloud & Context
Subscribe to get the latest posts sent to your email.