Skip to content

Virtual DOM and Server-Side Rendering — Complete Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Virtual Dom and Server. We cover key concepts, practical examples, and best practices to help you master this topic.

Server-Side Rendering with Virtual DOM generates HTML on the server using the same component model, improving initial load time, SEO, and perceived performance.

What You'll Learn

  • How SSR works with Virtual DOM frameworks
  • What hydration is and how it connects server HTML to client interactivity
  • The challenges of SSR with Virtual DOM
  • How streaming SSR and server components change the landscape

Why It Matters

Virtual DOM frameworks traditionally render entirely in the browser. SSR moves initial rendering to the server, sending ready-to-display HTML. This improves SEO, time-to-content, and Accessibility for users on slow connections.

flowchart LR
  A[Server request] --> B[Component tree on server]
  B --> C[Virtual DOM tree]
  C --> D[Render to HTML string]
  D --> E[Send HTML to client]
  E --> F[Browser displays HTML]
  F --> G[JavaScript loads]
  G --> H[Hydration]
  H --> I[Virtual DOM takes over]
  I --> J[Interactive component]

SSR with Virtual DOM

The server creates virtual DOM trees and converts them to HTML strings.

// Server-side: render virtual DOM to HTML string
function renderToString(vnode) {
    if (typeof vnode === 'string' || typeof vnode === 'number') {
        // Text node: escape and return
        return escapeHtml(String(vnode));
    }

    if (!vnode || typeof vnode !== 'object') {
        return '';
    }

    const { type, props } = vnode;
    const children = props.children || [];

    if (typeof type === 'function') {
        // Component: call it to get its VNode
        const componentVNode = type(props);
        return renderToString(componentVNode);
    }

    // HTML element
    let html = '<' + type;

    // Render attributes
    for (const [key, value] of Object.entries(props)) {
        if (key === 'children') continue;
        if (value === false || value === null || value === undefined) continue;
        if (key === 'className') key = 'class';

        if (key === 'style' && typeof value === 'object') {
            // Convert style object to string
            const styleStr = Object.entries(value)
                .map(([k, v]) => k.replace(/[A-Z]/g, m => '-' + m.toLowerCase()) + ': ' + v)
                .join('; ');
            html += ' style="' + escapeHtml(styleStr) + '"';
        } else {
            html += ' ' + key + '="' + escapeHtml(String(value)) + '"';
        }
    }

    if (children.length > 0) {
        html += '>';
        html += children.map(child => renderToString(child)).join('');
        html += '</' + type + '>';
    } else {
        // Self-closing for void elements
        html += ' />';
    }

    return html;
}

// Usage on server:
const appVNode = createApp();
const html = '<!DOCTYPE html>' + renderToString(appVNode);
// Send html to client as the response

Hydration: Making Server HTML Interactive

Hydration attaches event listeners to server-rendered HTML without recreating the DOM.

// Client-side hydration
import { hydrateRoot } from 'react-dom/client';

// The server sent HTML that looks like this:
// <div id="root">
//   <button class="counter">Clicked 0 times</button>
// </div>

// Hydration:
// React walks the existing DOM and attaches event listeners
// It does NOT create new DOM nodes — it reuses existing ones
// The virtual tree must match the server HTML exactly

hydrateRoot(
    document.getElementById('root'),
    createApp(initialState)
);

// During hydration:
// 1. React creates a virtual tree from the component
// 2. It walks the existing DOM instead of creating new nodes
// 3. It attaches event listeners and sets up state
// 4. If the virtual tree matches the DOM, hydration is complete
// 5. If there's a mismatch, React falls back to client rendering

// Hydration is one-time. After hydration, the app works normally
// with Virtual DOM updates from the client.

Hydration Mismatches

Mismatches between server HTML and client virtual tree cause problems.

// Common causes of hydration mismatches:

// 1. Browser-only code
function ThemeProvider({ children }) {
    // localStorage is not available on server
    // Server renders 'light', client renders 'dark'
    const theme = typeof window !== 'undefined'
        ? localStorage.getItem('theme') || 'light'
        : 'light';
    return <div className={'theme-' + theme}>{children}</div>;
}
// FIX: Use useEffect to read browser-only values after hydration

// 2. Non-deterministic content
function RandomValue() {
    // Math.random() gives different values on server and client
    return <p>Value: {Math.random()}</p>;
}
// FIX: Use deterministic values during SSR

// 3. Different component output
function DateDisplay({ timestamp }) {
    // Date formatting depends on locale, which may differ
    return <p>{new Date(timestamp).toLocaleDateString()}</p>;
}
// FIX: Pass locale explicitly

// 4. Third-party scripts modifying DOM
// (ads, analytics) that add elements during HTML parsing
// FIX: Wrap third-party content in client-only components

Streaming SSR

React 18's streaming SSR sends HTML in chunks as it renders.

import { renderToPipeableStream } from 'react-dom/server';
import { Suspense } from 'react';

// With streaming SSR, the server sends HTML progressively:
// 1. Shell HTML (layout, navigation) sent immediately
// 2. Suspense boundaries are streamed as they complete
// 3. The browser renders content as it arrives

function handleRequest(req, res) {
    const stream = renderToPipeableStream(<App />, {
        bootstrapScripts: ['/client.js'],
        onShellReady() {
            // Shell is ready — send it immediately
            res.statusCode = 200;
            res.setHeader('Content-Type', 'text/html');
            stream.pipe(res);
        },
        onShellError(err) {
            res.statusCode = 500;
            res.send('<!DOCTYPE html><p>Error</p>');
        },
        onError(err) {
            console.error(err);
        }
    });
}

// The <App> component might contain:
// <Layout>
//   <Nav />  <!-- Part of shell, sent immediately -->
//   <Suspense fallback={<Spinner />}>
//     <SlowAPIComponent />  <!-- Streamed when ready -->
//   </Suspense>
//   <Suspense fallback={<Spinner />}>
//     <AnotherSlowComponent />  <!-- Also streamed independently -->
//   </Suspense>
// </Layout>

// The browser receives the shell first, sees Spinners,
// then replaces each Spinner with actual content as it arrives.

Server Components

React Server Components run exclusively on the server and never send their code to the client.

// Server Component (runs only on server):
// App.server.js
import db from 'database';

async function UserList() {
    // Direct database access — no API endpoint needed
    const users = await db.query('SELECT * FROM users');

    return (
        <ul>
            {users.map(user => (
                <li key={user.id}>
                    {user.name}  {user.email}
                </li>
            ))}
        </ul>
    );
}
// This component NEVER sends JavaScript to the client
// Only its rendered HTML is included in the response

// Client Component (runs in browser):
// InteractiveButton.client.js
'use client';

import { useState } from 'react';

function InteractiveButton({ children }) {
    const [count, setCount] = useState(0);
    return (
        <button onClick={() => setCount(c => c + 1)}>
            {children} (clicked {count})
        </button>
    );
}
// This component's JavaScript IS sent to the browser

// Server Components + Client Components work together:
// <Page>
//   <ServerHeader />         <!-- HTML only, no JS -->
//   <InteractiveButton>      <!-- JS sent, hydrated -->
//     <ServerLabel />        <!-- Server content projected -->
//   </InteractiveButton>
//   <ServerFooter />         <!-- HTML only, no JS -->
// </Page>

Common Mistakes

  1. Using browser-only APIs (window, document, localStorage) during SSR without guarding.
  2. Creating hydration mismatches with non-deterministic content like Math.random() or Date.now().
  3. Not wrapping third-party widgets in client-only wrappers during SSR.
  4. Ignoring the performance cost of hydration for large component trees.
  5. Using SSR for highly dynamic, user-specific pages where SEO is not a concern.

Practice Questions

  1. What is SSR in the context of Virtual DOM? Rendering virtual components to HTML strings on the server instead of the client.
  2. What is hydration? The Process of attaching event listeners to server-rendered HTML without recreating the DOM.
  3. What causes hydration mismatches? Server and client rendering different output due to browser-only code or non-deterministic values.
  4. What is streaming SSR? Sending HTML to the client in chunks as components finish rendering on the server.

Challenge

Build a simple SSR framework using your virtual DOM implementation from the previous lessons. Create a server that receives a URL, renders the matching component to an HTML string, and sends it to the client. Implement hydration by adding a client-side script that re-attaches event listeners.

FAQ

What is SSR with Virtual DOM?

SSR renders virtual components to HTML strings on the server, sending ready-to-display HTML to the client. The client then hydrates to add interactivity.

What is hydration?

Hydration is the process where a Virtual DOM framework takes over server-rendered HTML, attaching event listeners and state without recreating DOM nodes.

What causes hydration mismatches?

Different output on server vs client due to browser-only code, non-deterministic values, or environment-specific behavior.

What is streaming SSR?

Streaming SSR sends HTML to the client in chunks as components render, improving time-to-first-byte and perceived performance.

What are React Server Components?

Server Components run exclusively on the server, accessing databases directly. Their JavaScript is never sent to the client, reducing bundle size.

Mini Project

Build a blog application with SSR. The server renders blog posts to HTML using a Virtual DOM renderer. The client hydrates the HTML to add interactivity (comments, likes). Implement streaming SSR for the comments section, which may load slowly from a database.

What's Next

Lesson 15: Building a Simple Virtual DOM

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro