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
TIP

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.

my-react-app/
├── 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.

src/config/seo.tsTypeScript
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";
i

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.

src/components/Document.tsxTSX
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

src/pages/HomePage.tsxTSX
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

src/pages/AboutPage.tsxTSX
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

src/pages/BlogPostPage.tsxTSX
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.

src/server.tsxTSX
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:

Final HTML output for /aboutHTML
<!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

FileResponsibilityWrites <html>?
config/seo.tsSite-wide SEO defaults (title template, OG, Twitter)No
components/Document.tsxHTML shell, <head>, <SEOHead>, <JsonLd>, nav, footerYes — the only file that does
pages/*.tsxMerge SEO config, create schemas, return content inside DocumentNo — never
server.tsxMatch routes, render pages with renderToString, send HTMLNo (just adds <!DOCTYPE html>)
VIEW

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.