Server-Side Rendering with Parcel: SSR, SSG, ISR Explained
Parcel has evolved from a simple zero-config bundler into a powerful build tool capable of handling full-stack applications. With the introduction of Parcel SSR support, developers can now build server-rendered applications, statically generate pages, and even implement incremental static regeneration — all without leaving the Parcel ecosystem. This tutorial walks you through what each rendering strategy means, why it matters, and how to implement them using Parcel.
What Is Server-Side Rendering?
Server-Side Rendering (SSR) is the process of generating HTML on the server for each incoming request, rather than relying entirely on the client to render content. When a user navigates to a page, the server fetches data, compiles the React (or other framework) components into HTML strings, and sends a fully-formed document to the browser. The client then "hydrates" that HTML, attaching event listeners and turning static markup into an interactive application.
Parcel supports three primary rendering strategies that build on this concept:
- SSR (Server-Side Rendering): HTML is generated on every request. Best for highly dynamic, personalized content.
- SSG (Static Site Generation): HTML is generated once at build time. Best for content that rarely changes.
- ISR (Incremental Static Regeneration): Static pages are regenerated in the background at a configurable interval. Best for content that updates periodically without needing a full rebuild.
Why Rendering Strategy Matters
Choosing the right rendering strategy affects performance, SEO, infrastructure costs, and user experience. Pure client-side rendering (CSR) ships a JavaScript bundle that the browser must download, parse, and execute before content appears — leading to slower First Contentful Paint and poor SEO crawling. SSR solves this by sending ready-to-read HTML, but it requires a running Node.js server for every request. SSG eliminates server costs entirely by pre-building pages, but it cannot serve personalized data. ISR sits in the middle, offering the performance of static pages with the freshness of server rendering.
Parcel's advantage is that it handles both the client and server bundles in a single unified pipeline. You write your components once, and Parcel automatically splits them into a browser bundle and a Node bundle, managing code splitting, tree shaking, and asset hashing for both targets.
Setting Up a Parcel SSR Project
Let's start by creating a new project and installing Parcel. We'll use React for the examples, but the same patterns apply to Preact, Vue, or other frameworks.
mkdir parcel-ssr-demo
cd parcel-ssr-demo
npm init -y
npm install --save-dev parcel@latest
npm install react react-dom express
Create a .parcelrc file to configure the bundler. This tells Parcel how to handle different file types and enables the server bundle target.
{
"extends": "@parcel/config-default",
"transformers": {
"*.{js,jsx,ts,tsx}": [
"@parcel/transformer-js"
]
}
}
Now define your package.json scripts. Parcel uses the --target flag to distinguish between browser and server builds.
{
"name": "parcel-ssr-demo",
"scripts": {
"dev": "parcel watch --target client & parcel watch --target server",
"build": "parcel build --target client && parcel build --target server",
"start": "node dist/server/index.js"
},
"targets": {
"client": {
"source": "src/client.js",
"distDir": "dist/client",
"context": "browser"
},
"server": {
"source": "src/server.js",
"distDir": "dist/server",
"context": "node",
"includeNodeModules": false
}
}
}
Creating the Shared Application Component
The key to SSR with Parcel is writing components that work both on the server and the client. Create a shared App component that will be imported by both entry points.
// src/App.js
import React, { useState, useEffect } from 'react';
export default function App({ initialData = {} }) {
const [data, setData] = useState(initialData);
const [count, setCount] = useState(0);
useEffect(() => {
// Only runs on the client
if (!initialData.title) {
fetch('/api/data')
.then(res => res.json())
.then(setData)
.catch(console.error);
}
}, []);
return (
<html>
<head>
<title>{data.title || 'Parcel SSR'}</title>
<meta charSet="utf-8" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div id="root">
<h1>{data.title || 'Loading...'}</h1>
<p>{data.description}</p>
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
</div>
<script src="/client.js" defer></script>
</body>
</html>
);
}
Notice that the component renders a full <html> document. This is a pattern borrowed from frameworks like Remix — the root component owns the entire document, which simplifies SSR because you don't need a separate HTML template.
Writing the Server Entry Point
The server entry point uses Express to handle incoming requests, fetch data, and render the React component to an HTML string using renderToString.
// src/server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './App.js';
const app = express();
const PORT = process.env.PORT || 3000;
// Serve static client assets
app.use(express.static('dist/client'));
// API endpoint for client-side data fetching
app.get('/api/data', (req, res) => {
res.json({
title: 'Hello from the Server',
description: 'This data was fetched from the API.'
});
});
// SSR route — renders on every request
app.get('*', (req, res) => {
const initialData = {
title: 'Rendered on the Server',
description: 'This HTML was generated server-side with Parcel.'
};
const html = renderToString(
React.createElement(App, { initialData })
);
res.status(200).send(`<!DOCTYPE html>${html}`);
});
app.listen(PORT, () => {
console.log(`SSR server running at http://localhost:${PORT}`);
});
Writing the Client Entry Point
The client entry point hydrates the server-rendered HTML. Hydration attaches React's event system to the existing DOM instead of replacing it, which is critical for performance and avoiding flicker.
// src/client.js
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
// Read server-provided initial data from a global variable
const initialData = window.__INITIAL_DATA__ || {};
hydrateRoot(
document,
React.createElement(App, { initialData })
);
To pass data from server to client, inject a script tag in your server render that sets window.__INITIAL_DATA__. Update the server route:
app.get('*', (req, res) => {
const initialData = {
title: 'Rendered on the Server',
description: 'This HTML was generated server-side with Parcel.'
};
const appHtml = renderToString(
React.createElement(App, { initialData })
);
const dataScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(initialData)};</script>`;
const fullHtml = appHtml.replace(
'</body>',
`${dataScript}</body>`
);
res.status(200).send(`<!DOCTYPE html>${fullHtml}`);
});
Implementing Static Site Generation (SSG)
SSG pre-renders pages at build time. Instead of running a server, you generate static HTML files that can be deployed to any CDN. This is ideal for blogs, documentation sites, and marketing pages.
Create a build script that iterates over your routes, fetches data for each, and writes HTML files to disk.
// scripts/generate-static.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { mkdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import App from '../src/App.js';
// Define your routes and their data sources
const routes = [
{
path: '/',
data: {
title: 'Home Page',
description: 'Welcome to our statically generated site.'
}
},
{
path: '/about',
data: {
title: 'About Us',
description: 'Learn more about our team.'
}
},
{
path: '/blog/post-1',
data: {
title: 'My First Post',
description: 'This is the content of the first blog post.'
}
}
];
const outputDir = 'dist/static';
async function generateStaticSite() {
mkdirSync(outputDir, { recursive: true });
for (const route of routes) {
const html = renderToString(
React.createElement(App, { initialData: route.data })
);
const dataScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(route.data)};</script>`;
const fullHtml = `<!DOCTYPE html>${html}`.replace(
'</body>',
`${dataScript}</body>`
);
// Determine output path — index.html for each route
const filePath = route.path === '/'
? join(outputDir, 'index.html')
: join(outputDir, route.path, 'index.html');
mkdirSync(join(filePath, '..'), { recursive: true });
writeFileSync(filePath, fullHtml);
console.log(`Generated: ${filePath}`);
}
console.log('Static site generation complete.');
}
generateStaticSite().catch(console.error);
Add this to your package.json scripts:
"generate": "node scripts/generate-static.js",
"build:ssg": "parcel build --target client && npm run generate"
Now running npm run build:ssg produces a dist/static directory with pre-rendered HTML files. Deploy this directory to Netlify, Vercel, GitHub Pages, or any static host.
Implementing Incremental Static Regeneration (ISR)
ISR combines the speed of SSG with the freshness of SSR. Pages are served statically, but a background process regenerates them at a configurable interval or on-demand. The first request after the revalidation window receives the stale page while a fresh version is built in the background.
To implement ISR with Parcel, you need a lightweight server that serves cached HTML and triggers regeneration when the cache expires. Here's a complete implementation:
// src/isr-server.js
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
import { join } from 'path';
import App from './App.js';
const app = express();
const PORT = process.env.PORT || 3000;
const CACHE_DIR = 'dist/isr-cache';
const REVALIDATE_SECONDS = 60; // Regenerate every 60 seconds
mkdirSync(CACHE_DIR, { recursive: true });
// Simulated data source — replace with a real database or API
async function fetchData(route) {
const timestamp = new Date().toISOString();
return {
title: `Page: ${route}`,
description: `Last updated at ${timestamp}`,
route
};
}
function getCachePath(route) {
const safeRoute = route === '/' ? 'index' : route.replace(/\//g, '_');
return join(CACHE_DIR, `${safeRoute}.html`);
}
function getCacheMetaPath(route) {
return getCachePath(route) + '.meta.json';
}
async function regeneratePage(route) {
const data = await fetchData(route);
const html = renderToString(
React.createElement(App, { initialData: data })
);
const dataScript = `<script>window.__INITIAL_DATA__ = ${JSON.stringify(data)};</script>`;
const fullHtml = `<!DOCTYPE html>${html}`.replace(
'</body>',
`${dataScript}</body>`
);
const cachePath = getCachePath(route);
writeFileSync(cachePath, fullHtml);
writeFileSync(getCacheMetaPath(route), JSON.stringify({
generatedAt: Date.now(),
route
}));
return fullHtml;
}
function isStale(route) {
const metaPath = getCacheMetaPath(route);
if (!existsSync(metaPath)) return true;
const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
const ageSeconds = (Date.now() - meta.generatedAt) / 1000;
return ageSeconds > REVALIDATE_SECONDS;
}
app.use(express.static('dist/client'));
app.get('*', async (req, res) => {
const route = req.path;
const cachePath = getCachePath(route);
// If no cache exists, generate synchronously (first request)
if (!existsSync(cachePath)) {
console.log(`[ISR] Cache miss for ${route}, generating...`);
const html = await regeneratePage(route);
return res.status(200).send(html);
}
// Serve cached version
const cachedHtml = readFileSync(cachePath, 'utf-8');
// If stale, regenerate in the background (non-blocking)
if (isStale(route)) {
console.log(`[ISR] Stale cache for ${route}, regenerating in background...`);
regeneratePage(route).catch(err => {
console.error(`[ISR] Regeneration failed for ${route}:`, err);
});
}
res.status(200).send(cachedHtml);
});
app.listen(PORT, () => {
console.log(`ISR server running at http://localhost:${PORT}`);
console.log(`Revalidation interval: ${REVALIDATE_SECONDS} seconds`);
});
This implementation follows the classic stale-while-revalidate pattern. The first visitor to a page triggers a synchronous generation. Subsequent visitors within the revalidation window receive the cached HTML instantly. After the window expires, the next visitor still gets the cached version, but the server kicks off a background regeneration so the following visitor sees fresh content.
On-Demand Revalidation
Sometimes you don't want to wait for the time-based interval. You can add an endpoint that triggers regeneration on demand — useful when content is updated via a CMS webhook.
app.post('/api/revalidate', async (req, res) => {
const { route, secret } = req.query;
if (secret !== process.env.REVALIDATE_SECRET) {
return res.status(401).json({ error: 'Invalid secret' });
}
if (!route) {
return res.status(400).json({ error: 'Route is required' });
}
try {
await regeneratePage(route);
res.json({ success: true, route, regeneratedAt: new Date().toISOString() });
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Best Practices
Keep Components Isomorphic
Write components that work in both Node and browser environments. Avoid accessing browser-only APIs like window or document at the top level of a component. Use useEffect for browser-specific logic, since useEffect does not run during server rendering.
// Bad — crashes on server
const width = window.innerWidth;
// Good — only runs in the browser
function MyComponent() {
const [width, setWidth] = useState(null);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
return <p>Width: {width ?? 'unknown'}</p>;
}
Serialize Data Safely
When injecting server data into window.__INITIAL_DATA__, always sanitize to prevent XSS attacks. Use JSON.stringify with a replacer that strips </script> sequences:
function safeSerialize(data) {
return JSON.stringify(data)
.replace(/<\/script/g, '<\\/script')
.replace(/<!--/g, '<\\!--');
}
const dataScript = `<script>window.__INITIAL_DATA__ = ${safeSerialize(initialData)};</script>`;
Handle Asset Paths Correctly
Parcel hashes asset filenames for cache busting. In SSR, reference the hashed client bundle from the server. You can read Parcel's bundle manifest or use a known public path. For production, configure publicUrl in your target config:
"targets": {
"client": {
"source": "src/client.js",
"distDir": "dist/client",
"publicUrl": "/assets/",
"context": "browser"
}
}
Stream Large Pages
For pages with heavy data dependencies, use renderToPipeableStream instead of renderToString. Streaming sends HTML chunks to the browser as they become available, improving Time to First Byte:
import { renderToPipeableStream } from 'react-dom/server';
app.get('*', (req, res) => {
const { pipe } = renderToPipeableStream(
React.createElement(App, { initialData }),
{
bootstrapScripts: ['/assets/client.js'],
onShellReady() {
res.setHeader('content-type', 'text/html');
pipe(res);
},
onError(error) {
console.error(error);
res.status(500).send('Internal Server Error');
}
}
);
});
Choose the Right Strategy Per Route
You don't have to pick one strategy for your entire application. A common pattern is to use SSG for marketing pages, SSR for user dashboards, and ISR for blog posts or product listings. Structure your server to route different paths to different handlers:
app.get('/', serveStaticPage); // SSG
app.get('/blog/*', serveISRPage); // ISR
app.get('/dashboard/*', renderSSRPage); // SSR
Monitor and Log Regeneration
For ISR, add logging and metrics around regeneration times and failure rates. A regeneration that silently fails leaves users with stale content indefinitely. Consider adding a maximum staleness threshold that forces synchronous regeneration if the background process keeps failing.
Conclusion
Parcel's unified build pipeline makes it surprisingly straightforward to implement SSR, SSG, and ISR without reaching for a heavyweight framework. By writing isomorphic components, configuring separate client and server targets, and choosing the right rendering strategy per route, you get the SEO benefits of server rendering, the performance of static generation, and the freshness of incremental regeneration — all with Parcel's signature zero-config developer experience. Start with SSR for dynamic pages, add SSG for static content, and introduce ISR where you need a balance of speed and freshness. As your application grows, the patterns shown here scale cleanly, letting you mix and match strategies without rewriting your component layer.