React + Express SSR Guide
Complete guide to using react-ssr-seo-toolkit in a React application with server-side rendering via Express, Fastify, or any Node.js server.
1How it works
In a React + Express SSR setup, your Express server renders React components to HTML strings using renderToString. The key architecture is:
- Document component handles
<html>,<head>,<SEOHead>, and<body> - Page components only return content and provide SEO config — they never write document-level tags
- Server matches routes and renders the correct page component
- Shared config defines site-wide SEO defaults that every page inherits
This is the same Document pattern used by Next.js, Remix, and React Router. Your pages focus on content. The Document handles the HTML shell.
2Project structure
Here is the recommended file organization. Notice that only the Document file deals with <html> and <head> tags.
├── src/
│ ├── server.tsx # Express entry point, routes
│ ├── config/
│ │ └── seo.ts # Site-wide SEO defaults
│ ├── components/
│ │ └── Document.tsx # <html>, <head>, <SEOHead>, <body>
│ └── pages/
│ ├── HomePage.tsx # Content only — no <html> tags
│ ├── AboutPage.tsx # Content only
│ ├── BlogListPage.tsx # Content only
│ └── BlogPostPage.tsx # Content only + JSON-LD
├── package.json
└── tsconfig.json
3Create shared SEO config
This file holds site-wide defaults. Every page inherits these values and only overrides what it needs. Create it once, import it everywhere.
import { createSEOConfig } from "react-ssr-seo-toolkit";
// Site-wide SEO defaults — every page inherits these
export const siteConfig = createSEOConfig({
titleTemplate: "%s | My App", // %s is replaced with page title
description: "My React application with great SEO.",
openGraph: {
siteName: "My App",
type: "website",
locale: "en_US",
},
twitter: {
card: "summary_large_image",
site: "@myapp",
},
});
export const SITE_URL = "https://myapp.com";Why a shared config? Instead of repeating siteName, twitter.site, etc. on every page, you define them once here. Pages only set their own title, description, and canonical.
4Create the Document component
The Document is the only file that writes <html>, <head>, and <body>. It receives the page's SEO config and optional JSON-LD schemas, and renders everything.
import React from "react";
import { SEOHead, JsonLd } from "react-ssr-seo-toolkit";
import type { SEOConfig } from "react-ssr-seo-toolkit";
interface DocumentProps {
children: React.ReactNode;
seo: SEOConfig;
schemas?: Record<string, unknown>[];
}
export function Document({ children, seo, schemas }: DocumentProps) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport"
content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{/* All SEO meta tags are rendered here */}
<SEOHead {...seo} />
{/* JSON-LD structured data from the page */}
{schemas?.map((schema, i) => (
<JsonLd key={i} data={schema} />
))}
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav>{/* Your shared navigation */}</nav>
<main>{children}</main>
<footer>{/* Your shared footer */}</footer>
</body>
</html>
);
}Important: Only the Document writes <html> and <head>. Page components should never include these tags. If you see <html> inside a page file, that is a mistake.
5Write page components
Each page component follows a simple pattern: merge the site config with page-specific SEO, pass it to the Document, and render content. No <html>, <head>, or <body> tags.
Home Page
import React from "react";
import {
mergeSEOConfig,
createOrganizationSchema,
createWebsiteSchema,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "../config/seo";
import { Document } from "../components/Document";
export function HomePage() {
const seo = mergeSEOConfig(siteConfig, {
title: "Home",
canonical: SITE_URL,
openGraph: {
title: "My App — Welcome",
url: SITE_URL,
images: [{ url: `${SITE_URL}/og-home.jpg`, width: 1200, height: 630 }],
},
});
// JSON-LD schemas for the home page
const schemas = [
createOrganizationSchema({
name: "My App",
url: SITE_URL,
logo: `${SITE_URL}/logo.png`,
}),
createWebsiteSchema({
name: "My App",
url: SITE_URL,
description: "My React application.",
}),
];
return (
<Document seo={seo} schemas={schemas}>
<h1>Welcome to My App</h1>
<p>This is the home page.</p>
</Document>
);
}About Page
import React from "react";
import { mergeSEOConfig, buildCanonicalUrl } from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "../config/seo";
import { Document } from "../components/Document";
export function AboutPage() {
const seo = mergeSEOConfig(siteConfig, {
title: "About Us",
description: "Learn about our mission and team.",
canonical: buildCanonicalUrl(SITE_URL, "/about"),
openGraph: {
title: "About Us — My App",
description: "Learn about our mission and team.",
url: buildCanonicalUrl(SITE_URL, "/about"),
},
});
// No JSON-LD needed for a simple about page
return (
<Document seo={seo}>
<h1>About Us</h1>
<p>Our mission is to build great software.</p>
</Document>
);
}Blog Post with JSON-LD
import React from "react";
import {
mergeSEOConfig,
buildCanonicalUrl,
createArticleSchema,
createBreadcrumbSchema,
} from "react-ssr-seo-toolkit";
import { siteConfig, SITE_URL } from "../config/seo";
import { Document } from "../components/Document";
// In a real app, this data comes from a database or CMS
const post = {
title: "Getting Started with SSR SEO",
excerpt: "Learn how to add SEO to your server-rendered React app.",
content: "Full article content here...",
author: "Jane Doe",
date: "2025-06-15",
slug: "getting-started-ssr-seo",
};
export function BlogPostPage() {
const url = buildCanonicalUrl(SITE_URL, `/blog/${post.slug}`);
const seo = mergeSEOConfig(siteConfig, {
title: post.title,
description: post.excerpt,
canonical: url,
openGraph: {
title: post.title,
description: post.excerpt,
url,
type: "article",
},
twitter: {
title: post.title,
description: post.excerpt,
},
});
const schemas = [
createArticleSchema({
headline: post.title,
url,
datePublished: post.date,
author: [{ name: post.author }],
publisher: { name: "My App", logo: `${SITE_URL}/logo.png` },
images: [`${SITE_URL}/images/${post.slug}.jpg`],
}),
createBreadcrumbSchema([
{ name: "Home", url: SITE_URL },
{ name: "Blog", url: buildCanonicalUrl(SITE_URL, "/blog") },
{ name: post.title, url },
]),
];
return (
<Document seo={seo} schemas={schemas}>
<article>
<h1>{post.title}</h1>
<p>By {post.author} on {post.date}</p>
<p>{post.content}</p>
</article>
</Document>
);
}6Set up the Express server
The server imports each page component and renders it for the matching route. Each page already includes the Document internally, so the server just calls renderToString and sends the result.
import express from "express";
import React from "react";
import { renderToString } from "react-dom/server";
import { HomePage } from "./pages/HomePage";
import { AboutPage } from "./pages/AboutPage";
import { BlogPostPage } from "./pages/BlogPostPage";
const app = express();
// Serve static files (CSS, images, etc.)
app.use(express.static("public"));
// Each page component includes Document internally.
// The server just renders and sends the HTML.
app.get("/", (req, res) => {
const html = renderToString(<HomePage />);
res.send(`<!DOCTYPE html>${html}`);
});
app.get("/about", (req, res) => {
const html = renderToString(<AboutPage />);
res.send(`<!DOCTYPE html>${html}`);
});
app.get("/blog/:slug", (req, res) => {
// In a real app, pass req.params.slug to fetch data
const html = renderToString(<BlogPostPage />);
res.send(`<!DOCTYPE html>${html}`);
});
app.listen(3000, () => {
console.log("Server running on http://localhost:3000");
});7What gets rendered
When a user visits /about, the server renders the AboutPage component. Because the Document wraps it, the final HTML output includes all SEO tags:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
<!-- Generated by SEOHead -->
<title>About Us | My App</title>
<meta name="description" content="Learn about our mission and team." />
<link rel="canonical" href="https://myapp.com/about" />
<meta property="og:title" content="About Us — My App" />
<meta property="og:description" content="Learn about our mission and team." />
<meta property="og:url" content="https://myapp.com/about" />
<meta property="og:site_name" content="My App" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="en_US" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@myapp" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav><!-- shared navigation --></nav>
<main>
<h1>About Us</h1>
<p>Our mission is to build great software.</p>
</main>
<footer><!-- shared footer --></footer>
</body>
</html>Summary
| File | Responsibility | Writes <html>? |
|---|---|---|
config/seo.ts | Site-wide SEO defaults (title template, OG, Twitter) | No |
components/Document.tsx | HTML shell, <head>, <SEOHead>, <JsonLd>, nav, footer | Yes — the only file that does |
pages/*.tsx | Merge SEO config, create schemas, return content inside Document | No — never |
server.tsx | Match routes, render pages with renderToString, send HTML | No (just adds <!DOCTYPE html>) |
See it in action: This entire demo site is built with React + Express SSR using react-ssr-seo-toolkit. Right-click → View Page Source on any page to inspect the generated SEO tags.