Front-end Engineering Lab

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

  1. Use ssr: false - For client-only components
  2. Streaming SSR - Use Suspense boundaries (React 18)
  3. Provide fallbacks - Avoid hydration mismatches
  4. Progressive hydration - Hydrate on visible/interaction
  5. Reserve space - Prevent CLS with skeletons
  6. Check typeof window - Before using browser APIs
  7. Monitor TTFB - Server render time matters

SSR + Code splitting = Instant content + Fast interaction. Best of both worlds! 🚀

On this page