PatternsCode Splitting Strategies
Code Splitting with SSR
Server-side rendering considerations for code splitting
Code Splitting with SSR
Code splitting with Server-Side Rendering requires special handling. This guide covers patterns for Next.js, Remix, and custom SSR setups.
🎯 The Challenge
CSR (Client-Side Rendering):
User requests / → HTML shell
→ Download JS bundle
→ Hydrate
→ Interactive
SSR (Server-Side Rendering):
User requests / → Full HTML (instant content!)
→ Download JS bundle
→ Hydrate (match server HTML)
→ Interactive
Problem: Code splitting can cause hydration mismatches!⚠️ Hydration Mismatch
// ❌ BAD: Different on server vs client
function Component() {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <div>Loading...</div>; // Server renders this
}
return <div>Content</div>; // Client renders this
// ⚠️ Hydration mismatch!
}🔧 Next.js Dynamic Imports
Client-Side Only Components
import dynamic from 'next/dynamic';
// Component that can't run on server (uses window, document, etc)
const ClientOnlyComponent = dynamic(
() => import('./ClientOnlyComponent'),
{ ssr: false } // Don't render on server!
);
function Page() {
return (
<div>
<h1>Server-rendered content</h1>
{/* This only renders on client */}
<ClientOnlyComponent />
</div>
);
}With Loading State
const DashboardChart = dynamic(
() => import('./DashboardChart'),
{
loading: () => <ChartSkeleton />,
ssr: false
}
);
function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
{/* Shows skeleton on both server and client */}
<DashboardChart />
</div>
);
}Named Exports
// Component.tsx
export function HeavyComponent() {
return <div>Heavy component</div>;
}
// Page.tsx
const HeavyComponent = dynamic(
() => import('./Component').then(mod => mod.HeavyComponent),
{ ssr: false }
);🎨 SSR-Safe Code Splitting
Check if Client-Side
import { lazy, Suspense } from 'react';
const ClientComponent = lazy(() => import('./ClientComponent'));
function Page() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return (
<div>
<h1>Server and client see this</h1>
{isClient && (
<Suspense fallback={<Spinner />}>
<ClientComponent />
</Suspense>
)}
</div>
);
}Custom Hook
function useIsClient() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient;
}
// Usage
function Component() {
const isClient = useIsClient();
if (!isClient) {
return <ServerFallback />;
}
return <ClientOnlyFeature />;
}📦 Preloading on Server
Next.js Route Prefetching
// Next.js automatically prefetches routes in viewport
import Link from 'next/link';
function Navigation() {
return (
<nav>
{/* Prefetched automatically when link is visible */}
<Link href="/dashboard">Dashboard</Link>
{/* Disable prefetch */}
<Link href="/heavy-page" prefetch={false}>
Heavy Page
</Link>
</nav>
);
}Manual Prefetch
import { useRouter } from 'next/router';
function Navigation() {
const router = useRouter();
const handleMouseEnter = () => {
// Prefetch on hover
router.prefetch('/dashboard');
};
return (
<button
onMouseEnter={handleMouseEnter}
onClick={() => router.push('/dashboard')}
>
Go to Dashboard
</button>
);
}🔄 Streaming SSR (React 18)
Suspense Boundaries
import { Suspense } from 'react';
export default function Page() {
return (
<div>
{/* This renders immediately */}
<Header />
{/* This streams in when ready */}
<Suspense fallback={<ProductsSkeleton />}>
<Products />
</Suspense>
{/* This also streams in when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</div>
);
}
// Server sends HTML in chunks:
// 1. Header (instant)
// 2. Products skeleton (instant)
// 3. Reviews skeleton (instant)
// 4. Products (when data ready)
// 5. Reviews (when data ready)Next.js App Router (Streaming)
// app/page.tsx
import { Suspense } from 'react';
export default function Page() {
return (
<>
<Header />
{/* Streamed component */}
<Suspense fallback={<Skeleton />}>
<ServerComponent />
</Suspense>
</>
);
}
// app/ServerComponent.tsx
async function ServerComponent() {
// This async data fetch won't block the page
const data = await fetchData();
return <div>{data}</div>;
}🎯 Progressive Hydration
Hydrate on Visible
import { useInView } from 'react-intersection-observer';
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function Page() {
const { ref, inView } = useInView({
triggerOnce: true,
rootMargin: '100px'
});
return (
<div>
<div ref={ref}>
{inView ? (
<Suspense fallback={<Skeleton />}>
<HeavyComponent />
</Suspense>
) : (
<Skeleton />
)}
</div>
</div>
);
}Hydrate on Interaction
function LazyHydrate({ children }: { children: React.ReactNode }) {
const [hydrated, setHydrated] = useState(false);
return (
<div
onMouseOver={() => setHydrated(true)}
onClick={() => setHydrated(true)}
>
{hydrated ? children : <div dangerouslySetInnerHTML={{ __html: serverHTML }} />}
</div>
);
}
// Usage
<LazyHydrate>
<InteractiveComponent />
</LazyHydrate>🚀 Remix Considerations
Route-Based Code Splitting (Automatic)
// routes/index.tsx → Automatically split
export default function Index() {
return <h1>Home</h1>;
}
// routes/dashboard.tsx → Automatically split
export default function Dashboard() {
return <h1>Dashboard</h1>;
}
// Remix handles SSR + code splitting automatically!Client-Only in Remix
import { ClientOnly } from 'remix-utils';
export default function Page() {
return (
<div>
<h1>Server-rendered</h1>
<ClientOnly>
{() => <ClientOnlyComponent />}
</ClientOnly>
</div>
);
}📊 Measuring SSR Performance
// pages/_app.tsx (Next.js)
export function reportWebVitals(metric: NextWebVitalsMetric) {
switch (metric.name) {
case 'FCP':
console.log('First Contentful Paint:', metric.value);
break;
case 'LCP':
console.log('Largest Contentful Paint:', metric.value);
break;
case 'TTFB':
console.log('Time to First Byte:', metric.value);
break;
}
// Send to analytics
analytics.track('web-vitals', {
name: metric.name,
value: metric.value,
label: metric.label
});
}🎨 Avoiding Layout Shift
Reserve Space for Lazy Components
const HeavyComponent = dynamic(
() => import('./HeavyComponent'),
{
loading: () => (
<div style={{ height: '400px' }}>
{/* Reserve space to avoid CLS */}
<Skeleton />
</div>
),
ssr: false
}
);Skeleton Loaders
function ProductSkeleton() {
return (
<div className="product-skeleton" style={{ height: '300px' }}>
<div className="skeleton-image" style={{ height: '200px' }} />
<div className="skeleton-title" style={{ height: '20px', width: '80%' }} />
<div className="skeleton-price" style={{ height: '20px', width: '40%' }} />
</div>
);
}
const Product = dynamic(() => import('./Product'), {
loading: () => <ProductSkeleton />
});🔍 Debugging SSR Issues
Check if Running on Server
function Component() {
const isServer = typeof window === 'undefined';
console.log('Running on:', isServer ? 'server' : 'client');
if (isServer) {
// Server-only logic
return <ServerVersion />;
}
return <ClientVersion />;
}Hydration Error Debugging
// pages/_app.tsx (Next.js)
function MyApp({ Component, pageProps }: AppProps) {
useEffect(() => {
// Listen for hydration errors
window.addEventListener('error', (e) => {
if (e.message.includes('Hydration')) {
console.error('Hydration error:', e);
// Send to error tracking
errorTracker.captureException(e);
}
});
}, []);
return <Component {...pageProps} />;
}📚 Best Practices
1. Use ssr: false for Client-Only
const Map = dynamic(() => import('./Map'), { ssr: false });
const Chart = dynamic(() => import('./Chart'), { ssr: false });
const Editor = dynamic(() => import('./Editor'), { ssr: false });2. Provide Loading States
// Always show something while loading
const Component = dynamic(
() => import('./Component'),
{
loading: () => <Skeleton />,
ssr: false
}
);3. Check for Window/Document
function Component() {
if (typeof window === 'undefined') {
return null; // Or server fallback
}
// Safe to use window/document
return <div>{window.innerWidth}</div>;
}4. Use Streaming SSR (React 18)
// Let expensive components stream in
<Suspense fallback={<Skeleton />}>
<ExpensiveComponent />
</Suspense>🏢 Real-World Examples
Next.js Commerce
// Client-only cart
const Cart = dynamic(() => import('./Cart'), { ssr: false });
// Server-rendered product list
const ProductList = dynamic(() => import('./ProductList'));Vercel Dashboard
// Charts are client-only
const Analytics = dynamic(() => import('./Analytics'), { ssr: false });
// Main content is server-rendered
const Dashboard = dynamic(() => import('./Dashboard'));📚 Key Takeaways
- Use
ssr: false- For client-only components - Streaming SSR - Use Suspense boundaries (React 18)
- Provide fallbacks - Avoid hydration mismatches
- Progressive hydration - Hydrate on visible/interaction
- Reserve space - Prevent CLS with skeletons
- Check
typeof window- Before using browser APIs - Monitor TTFB - Server render time matters
SSR + Code splitting = Instant content + Fast interaction. Best of both worlds! 🚀