Virtual DOM and Server-Side Rendering — Complete Guide
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
- Using browser-only APIs (window, document, localStorage) during SSR without guarding.
- Creating hydration mismatches with non-deterministic content like Math.random() or Date.now().
- Not wrapping third-party widgets in client-only wrappers during SSR.
- Ignoring the performance cost of hydration for large component trees.
- Using SSR for highly dynamic, user-specific pages where SEO is not a concern.
Practice Questions
- What is SSR in the context of Virtual DOM? Rendering virtual components to HTML strings on the server instead of the client.
- What is hydration? The Process of attaching event listeners to server-rendered HTML without recreating the DOM.
- What causes hydration mismatches? Server and client rendering different output due to browser-only code or non-deterministic values.
- 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
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