Server-Side Rendering (SSR) Guide
Learn how to implement server-side rendering with Coherent.js for fast initial page loads and SEO-friendly applications.
Why Server-Side Rendering?
Server-side rendering provides several benefits:
- Fast Initial Load: HTML is rendered on the server, reducing time-to-first-paint
- SEO Friendly: Search engines can crawl fully-rendered HTML
- Progressive Enhancement: Works even with JavaScript disabled
- Better Performance: Reduced client-side computation
- Social Media: Meta tags and OpenGraph work correctly
Basic SSR Setup
Simple Server-Side Rendering
import { render } from '@coherent.js/core';
import http from 'http';
// Define your component
const HomePage = ({ title, user }) => ({
html: {
children: [
{ head: {
children: [
{ title: { text: title } },
{ meta: { charset: 'utf-8' } },
{ meta: { name: 'viewport', content: 'width=device-width, initial-scale=1' } }
]
}},
{ body: {
children: [
{ h1: { text: `Welcome, ${user.name}!` } },
{ p: { text: 'This page was rendered on the server.' } }
]
}}
]
}
});
// HTTP server
const server = http.createServer((req, res) => {
const component = HomePage({
title: 'My SSR App',
user: { name: 'John Doe' }
});
const html = render(component);
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<!DOCTYPE html>${html}`);
});
server.listen(3000, () => {
console.log('SSR Server running on http://localhost:3000');
});Handling Render Errors
render() is synchronous. If a component throws, render() throws a RenderingError naming the component's path (the original error is its cause), so you can answer with a 500 instead of a half-rendered page:
import { render } from '@coherent.js/core';
import http from 'node:http';
const server = http.createServer(async (req, res) => {
try {
const user = await loadUser(req); // await data first
const html = render(HomePage({ title: 'My App', user }));
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(`<!DOCTYPE html>${html}`);
} catch (error) {
console.error('SSR Error:', error);
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('Internal Server Error');
}
});To keep rendering when one component fails, pass onError; its return value is rendered in place of the component (null leaves it out):
const html = render(Page(data), {
onError: (error, { path }) => {
console.error(`Component at ${path} failed`, error);
return { p: { className: 'unavailable', text: 'This section is unavailable.' } };
}
});Complete HTML Document Structure
Full Page Component
const DocumentLayout = ({ title, description, head = [], children, scripts = [], styles = [] }) => ({
html: {
lang: 'en',
children: [
{ head: {
children: [
{ meta: { charset: 'utf-8' } },
{ meta: { name: 'viewport', content: 'width=device-width, initial-scale=1' } },
{ title: { text: title } },
{ meta: { name: 'description', content: description } },
// CSS files
...styles.map(href => ({
link: { rel: 'stylesheet', href }
})),
// Inline critical CSS
{ style: {
text: `
body { font-family: Arial, sans-serif; margin: 0; padding: 20px; }
.container { max-width: 1200px; margin: 0 auto; }
`
}},
// Page-specific head elements
...head
]
}},
{ body: {
children: [
{ div: {
className: 'container',
children: Array.isArray(children) ? children : [children]
}},
// JavaScript files
...scripts.map(src => ({
script: { src, defer: true }
}))
]
}}
]
}
});Dynamic Meta Tags
const BlogPost = ({ post, baseUrl }) => {
const fullUrl = `${baseUrl}/posts/${post.slug}`;
return DocumentLayout({
title: `${post.title} | My Blog`,
description: post.excerpt,
head: [
// OpenGraph
{ meta: { property: 'og:title', content: post.title } },
{ meta: { property: 'og:description', content: post.excerpt } },
{ meta: { property: 'og:image', content: post.featuredImage } },
{ meta: { property: 'og:url', content: fullUrl } },
{ meta: { property: 'og:type', content: 'article' } },
// Twitter Card
{ meta: { name: 'twitter:card', content: 'summary_large_image' } },
{ meta: { name: 'twitter:title', content: post.title } }
],
children: [
{ article: {
children: [
{ h1: { text: post.title } },
{ time: { datetime: post.publishedAt, text: new Date(post.publishedAt).toLocaleDateString() } },
// `html` is inserted as-is: only use it for content you have sanitized
{ div: { className: 'content', html: post.contentHtml } }
]
}}
],
styles: ['/css/blog.css'],
scripts: ['/js/blog.js']
});
};generateMeta() from @coherent.js/seo builds the title, description, Open Graph and Twitter tags in one call (see SEO).
Data Fetching for SSR
Async Data Loading
Load everything a page needs, then render synchronously:
import { render } from '@coherent.js/core';
import { createDatabaseManager, executeQuery } from '@coherent.js/database';
const db = createDatabaseManager({ type: 'postgresql', host: 'localhost', database: 'blog' });
await db.connect();
async function renderBlogPost(slug) {
const { rows: [post] } = await executeQuery(db, {
table: 'posts',
where: { slug, published: true },
limit: 1
});
if (!post) return null;
const { rows: comments } = await executeQuery(db, {
table: 'comments',
select: ['id', 'author', 'content', 'created_at'],
where: { post_id: post.id, approved: true },
orderBy: { created_at: 'ASC' }
});
return render(BlogPostWithComments({ post, comments }));
}
const server = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://localhost');
if (!url.pathname.startsWith('/posts/')) {
res.writeHead(404).end();
return;
}
try {
const html = await renderBlogPost(url.pathname.slice('/posts/'.length));
res.writeHead(html ? 200 : 404, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html ? `<!DOCTYPE html>${html}` : '<!DOCTYPE html><h1>Post not found</h1>');
} catch (error) {
console.error(error);
res.writeHead(500).end('Internal Server Error');
}
});An async component, or any Promise left in the tree, makes render() throw (Cannot render a Promise at <path>).
Caching SSR Results
import { render } from '@coherent.js/core';
// Simple in-memory cache for rendered pages
const pageCache = new Map();
async function renderWithCache(cacheKey, renderFunction) {
// Check cache first
if (pageCache.has(cacheKey)) {
const cached = pageCache.get(cacheKey);
if (Date.now() - cached.timestamp < 300000) { // 5 minutes
return cached.html;
}
pageCache.delete(cacheKey);
}
// Render and cache
const html = await renderFunction();
pageCache.set(cacheKey, {
html,
timestamp: Date.now()
});
return html;
}
// Usage
async function handleBlogPost(slug) {
return renderWithCache(`post:${slug}`, async () => {
const postData = await fetchPostData(slug);
const component = BlogPost(postData);
return render(component);
});
}Streaming SSR
Streaming Large Pages
renderToStream() is an async generator of HTML chunks with exactly render()'s output. The event loop gets a turn after every chunk, so the first bytes of a large page leave early and other requests keep being served:
import http from 'node:http';
import { Readable } from 'node:stream';
import { renderToStream, streamingUtils } from '@coherent.js/core';
// Component with large content
const LargePage = ({ products = [] }) => ({
html: {
children: [
{ head: { children: [{ title: { text: 'Product Catalog' } }] } },
{ body: {
children: [
{ h1: { text: 'Our Products' } },
{ div: {
className: 'products-grid',
children: products.map(product => ({
div: {
className: 'product-card',
children: [
{ h3: { text: product.name } },
{ p: { text: product.description } },
{ span: { text: `${product.price}` } }
]
}
}))
}}
]
}}
]
}
});
const server = http.createServer(async (req, res) => {
if (req.url !== '/products') {
res.writeHead(404).end();
return;
}
const products = await fetchAllProducts(); // load data before streaming
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.write('<!DOCTYPE html>');
try {
// Writes with backpressure
await streamingUtils.streamToResponse(renderToStream(LargePage({ products }), { chunkSize: 16384 }), res);
} catch (error) {
// The status line is already sent, so the response is aborted instead of
// ending as a truncated 200
console.error('Streaming failed:', error);
}
});
// Or pipe it:
// Readable.from(renderToStream(LargePage({ products }))).pipe(res);Errors propagate out of the iteration (and onError works as in render()). Streaming trades total render time for time-to-first-byte; for small pages render() is faster. When you use the SSR context API of @coherent.js/state, wrap a streaming render in runWithContext().
Progressive Content Loading
const ProgressivePage = ({ initialData, loadingPlaceholders }) => ({
html: {
children: [
{ head: {
children: [
{ title: { text: 'Dashboard' } },
{ script: {
text: `
// Client-side loading script
async function loadSection(sectionId, url) {
const element = document.getElementById(sectionId);
try {
const response = await fetch(url);
const html = await response.text();
element.innerHTML = html;
} catch (error) {
element.innerHTML = '<p>Error loading content</p>';
}
}
// Load sections when page is ready
document.addEventListener('DOMContentLoaded', () => {
loadSection('analytics', '/api/sections/analytics');
loadSection('recent-activity', '/api/sections/activity');
});
`
}}
]
}},
{ body: {
children: [
{ h1: { text: 'Dashboard' } },
// Immediately available content
{ section: {
children: [
{ h2: { text: 'Overview' } },
{ p: { text: `Welcome back, ${initialData.user.name}!` } }
]
}},
// Placeholder for lazy-loaded content
{ section: {
id: 'analytics',
className: 'loading',
children: [
{ div: { className: 'spinner' } },
{ p: { text: 'Loading analytics...' } }
]
}},
{ section: {
id: 'recent-activity',
className: 'loading',
children: [
{ div: { className: 'spinner' } },
{ p: { text: 'Loading recent activity...' } }
]
}}
]
}}
]
}
});Error Handling in SSR
Error Boundaries
A function component that throws makes render() throw. Handle failures where you can do something useful:
import { render, createErrorBoundary } from '@coherent.js/core';
// 1. Wrap a component that may fail
const SafeRecommendations = createErrorBoundary({
fallback: { p: { text: 'Recommendations are unavailable right now.' } },
onError: (error) => console.error('Recommendations failed:', error)
})(Recommendations);
const Page = (data) => ({
main: {
children: [
Article(data.article),
SafeRecommendations(data)
]
}
});
// 2. Or decide for every component at render time
const html = render(Page(data), {
onError: (error, { path }) => (process.env.NODE_ENV === 'development'
? { pre: { text: `${path}: ${error.stack}` } }
: null)
});On the server, a boundary starts from a clean state on every call, so one failed request does not make later requests render the fallback.
Graceful Degradation
const RobustComponent = ({ data, fallback }) => {
try {
// Validate required data
if (!data || !data.items || !Array.isArray(data.items)) {
throw new Error('Invalid data structure');
}
return {
div: {
className: 'content',
children: data.items.map(item => ({
div: {
className: 'item',
children: [
{ h3: { text: item.title || 'Untitled' } },
{ p: { text: item.description || 'No description available' } }
]
}
}))
}
};
} catch (error) {
console.warn('Component rendering failed, using fallback:', error.message);
return fallback || {
div: {
className: 'fallback-content',
children: [
{ p: { text: 'Content temporarily unavailable' } }
]
}
};
}
};Performance Optimization
Rendering options for production
import { render } from '@coherent.js/core';
const html = render(component, { minify: true, maxDepth: 100 });minify: reduces HTML size; check the output if you rely on whitespace.maxDepth(default 100): a guard against accidentally deep trees.- Caching is off by default.
enableCache: truestores whole renders keyed on the complete component tree; it only pays off when identical trees are rendered again, and trees containing functions are never cached. Give it its own bounded cache:
import { render, createCacheManager } from '@coherent.js/core';
const pageCache = createCacheManager({ maxCacheSize: 500, ttlMs: 5 * 60 * 1000 });
const html = render(StaticPage(), { enableCache: true, cache: pageCache });cacheSize is deprecated and ignored. For expensive components rendered with the same props, memo() is usually the better tool.
Memoizing Static Components
import { memo } from '@coherent.js/core';
// Memoize static components to avoid re-rendering
const Header = memo(() => ({
header: {
children: [
{ h1: { text: 'My Website' } },
{ nav: {
children: [
{ a: { href: '/', text: 'Home' } },
{ a: { href: '/about', text: 'About' } },
{ a: { href: '/contact', text: 'Contact' } }
]
}}
]
}
});
// Use in pages
const HomePage = ({ content }) => ({
html: {
children: [
{ head: { children: [{ title: { text: 'Home' } }] }},
{ body: {
children: [
Header(), // Memoized - only rendered once
{ main: { children: content } }
]
}}
]
}
});Monitoring
import { render, performanceMonitor } from '@coherent.js/core';
const html = render(component, { enableMonitoring: true });
setInterval(() => {
const { metrics } = performanceMonitor.generateReport();
console.log('SSR stats:', {
avgRenderMs: metrics.renderTime.avg,
heapMB: Math.round(process.memoryUsage().heapUsed / 1024 / 1024)
});
}, 60_000).unref();A cache from createCacheManager() is bounded by maxCacheSize and maxMemoryMB; call pageCache.clear() to drop it.
SEO Optimization
Structured Data
Use @coherent.js/seo rather than JSON.stringify into a script: it escapes <, > and & so no value can close the <script> element.
import { generateStructuredData } from '@coherent.js/seo';
const ProductPage = ({ product }) => ({
html: {
children: [
{ head: {
children: [
{ title: { text: `${product.name} | My Store` } },
{ meta: { name: 'description', content: product.description } },
generateStructuredData('product', {
name: product.name,
description: product.description,
image: product.images,
offers: {
price: product.price,
currency: 'USD',
availability: product.inStock ? 'https://schema.org/InStock' : 'https://schema.org/OutOfStock'
}
})
]
}},
{ body: {
children: [
{ h1: { text: product.name } },
{ img: { src: product.images[0], alt: product.name } },
{ p: { text: product.description } }
]
}}
]
}
});Sitemap Generation
import { generateSitemap } from '@coherent.js/seo';
import { executeQuery } from '@coherent.js/database';
async function sitemapXml(db) {
const { rows: pages } = await executeQuery(db, {
table: 'pages',
select: ['slug', 'updated_at'],
where: { published: true }
});
return generateSitemap(
pages.map((page) => ({
url: `/${page.slug}`,
lastmod: new Date(page.updated_at).toISOString().slice(0, 10),
changefreq: 'weekly',
priority: 0.8
})),
{ hostname: 'https://mysite.com' }
);
}
app.get('/sitemap.xml', async (req, res) => {
res.type('application/xml').send(await sitemapXml(db));
});Next Steps
- Express Integration - Use with Express.js
- Client-Side Hydration - Add interactivity
- Performance Optimization - Advanced performance techniques