My Blog

Comprehensive React SEO Implementation with Vite: SSG, Hydration, and React Helmet

2025-04-21 Hanlun Wang

1. SEO Requirements Analysis

1.1 SEO Challenges in React Applications

Single Page Applications (SPAs) built with frameworks like React inherently face SEO challenges:

  • Traditional SPAs rely on client-side JavaScript rendering, making it difficult for search engine crawlers to capture complete content
  • Initial HTML typically contains only an empty root element, lacking meaningful content and metadata
  • Dynamically generated content is absent during the initial page load
  • Meta tags do not update dynamically as routes change
  • Route-based content isn't accessible via direct URLs for search engines

Modern SEO optimization approaches include:

  1. Server-Side Rendering (SSR): Generating complete HTML on the server
  2. Static Site Generation (SSG): Pre-rendering all routes as static HTML
  3. Client Hydration: Browser takes over interactivity after initial HTML load
  4. Dynamic Metadata Management: Generating appropriate SEO tags based on routes
  5. Structured Data Integration: Providing machine-readable context about page content

1.2 SEO Strategy

An effective SEO implementation for React applications should include:

  • Structured Data (Schema.org): Enhancing search engine result displays with rich snippets
  • Geographic Optimization: Tailoring SEO for specific geographic areas to improve local search rankings
  • Industry & Service Keyword Optimization: Customizing metadata for specific industries and services
  • Dynamic Page Titles & Descriptions: Unique SEO information for each route
  • Canonical URLs: Preventing duplicate content issues
  • Semantic HTML Structure: Providing clear content hierarchy for search engines
  • Mobile Optimization: Ensuring content is accessible and properly formatted for mobile devices

2. Complete SEO Implementation Process

2.1 Project Configuration Setup

2.1.1 Vite Configuration

First, we'll configure Vite to support our SSG process. In vite.config.ts, we set up the build environment and plugins:

import { defineConfig, loadEnv, ConfigEnv, UserConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import path from "path";
import { resolve } from "path";
 
export default defineConfig(({ mode }: ConfigEnv): UserConfig => {
  // load env variables
  const env = loadEnv(mode, process.cwd(), "");
  // if is production
  const isProd = mode === "production";
 
  return {
    server: {
      host: "::",
      port: 8080,
      proxy: isProd
        ? undefined
        : {
            "/api": {
              target: "https://api.example.com",
              changeOrigin: true,
              rewrite: (path: string) => path.replace(/^\/api/, ""),
              secure: false,
            },
          },
    },
    plugins: [
      react(),
      {
        name: "generate-static-html",
        apply: "build",
        enforce: "post",
        // this hook will be executed after the build is complete
        closeBundle: async () => {
          if (mode === "production") {
            console.log("Running static site generation...");
          }
        },
      },
    ],
    resolve: {
      alias: {
        "@": path.resolve(__dirname, "./src"),
      },
    },
    build: {
      outDir: "dist",
      // keep comments for replacement
      minify: isProd ? "esbuild" : false,
      rollupOptions: {
        input: {
          main: resolve(__dirname, "index.html"),
        },
      },
    },
    preview: {
      port: 8080,
      open: true,
    },
    ssr: {
      noExternal: ["react-helmet-async"],
    },
  };
});

This configuration includes:

  • Development server setup with API proxying
  • Post-build hook for static site generation
  • Path aliases for cleaner imports
  • Build output configuration
  • SSR-specific settings to handle external dependencies

2.1.2 HTML Template Setup

Create an index.html file at the project root with an SSR outlet placeholder:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- SEO tags will be injected here by react-helmet-async -->
  </head>
 
  <body>
    <div id="root"><!--ssr-outlet--></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

The <!--ssr-outlet--> comment serves as a marker where server-rendered HTML will be injected.

2.1.3 Package.json Scripts

Configure the build process in package.json:

"scripts": {
  "dev": "vite",
  "build": "npm run build:client && npm run build:server && npm run generate",
  "build:client": "vite build --outDir dist",
  "build:server": "vite build --outDir dist/server --ssr src/entry-server.tsx",
  "generate": "node --experimental-json-modules scripts/generate-static.js",
  "build:dev": "npm run build:client:dev && npm run build:server:dev && npm run generate",
  "build:client:dev": "vite build --outDir dist --mode development",
  "build:server:dev": "vite build --outDir dist/server --ssr src/entry-server.tsx --mode development",
  "lint": "eslint .",
  "preview": "vite preview"
}

This defines a three-step build process:

  1. Client-side build (build:client)
  2. Server-side build (build:server)
  3. Static site generation (generate)

Additionally, it includes development-specific build scripts and debugging tools.

2.1.4 Route Configuration

Define application routes in a dedicated file for easy reference during static generation:

// routes.js
export const routes = [
  "/",
  "/page1",
  "/page2",
  "/page3",
  "/page4",
  "/page5",
  "*", // 404 page
];

This array contains all routes that will be pre-rendered during the static generation process.

2.2 Entry Point Configuration

2.2.1 Main Entry Point

Create a minimal main.tsx file that imports the client-side entry:

// src/main.tsx
// This file is simply an entry point to the client hydration code
import "./entry-client";

2.2.2 Server-Side Entry Point

Implement server-side rendering in entry-server.tsx:

// src/entry-server.tsx
import React from "react";
import { renderToString } from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import { HelmetProvider, HelmetServerState } from "react-helmet-async";
import App from "./App";
 
// Patch React's useLayoutEffect during SSR to behave like useEffect
// This prevents warnings from libraries like Radix UI that use useLayoutEffect internally
if (typeof window === "undefined") {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  (React as any).useLayoutEffect = React.useEffect;
}
 
export function render(url: string) {
  // Create a HelmetContext to capture SEO data
  const helmetContext: { helmet?: HelmetServerState } = {};
 
  // We're not using StrictMode in SSR to match exact DOM structure with hydration
  const appHtml = renderToString(
    <StaticRouter location={url}>
      <HelmetProvider context={helmetContext}>
        <App />
      </HelmetProvider>
    </StaticRouter>
  );
 
  // Get the Helmet data for SEO
  const { helmet } = helmetContext;
 
  // Log for debugging
  console.log("--------------------------------");
  console.log(`[SSR] Rendered URL: ${url}, Helmet data available: ${!!helmet}`);
  if (helmet) {
    console.log(`[SSR] Title: ${helmet.title.toString()}`);
    console.log(
      `[SSR] Meta tags count: ${
        (helmet.meta.toString().match(/<meta/g) || []).length
      }`
    );
  } else {
    console.warn(`[SSR] No Helmet data captured for ${url}`);
  }
  console.log("--------------------------------");
 
  return {
    html: appHtml,
    helmetData: helmet, // Return the raw helmet data
  };
}

This server-side entry point:

  • Patches React's useLayoutEffect to work in a server environment
  • Sets up a StaticRouter for routing during server rendering
  • Uses HelmetProvider to capture SEO data
  • Returns both rendered HTML and collected SEO metadata

2.2.3 Client-Side Entry Point

Implement client-side hydration in entry-client.tsx:

// src/entry-client.tsx
import React from "react";
import { hydrateRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import { HelmetProvider } from "react-helmet-async";
import App from "./App";
import "./index.css";
import ScrollToAnchor from "@/components/ui/ScrollToAnchor";
 
const rootElement = document.getElementById("root");
 
if (!rootElement) {
  console.error("Root element not found! Unable to hydrate React application.");
} else {
  // Use hydrateRoot for client-side hydration of server-rendered content
  hydrateRoot(
    rootElement,
    <React.StrictMode>
      <BrowserRouter>
        <HelmetProvider>
          <ScrollToAnchor />
          <App />
        </HelmetProvider>
      </BrowserRouter>
    </React.StrictMode>
  );
}

This client entry point:

  • Uses hydrateRoot instead of createRoot to preserve server-rendered DOM
  • Sets up browser-specific routing with BrowserRouter
  • Provides the same HelmetProvider for client-side SEO updates

2.3 SEO Metadata Configuration

2.3.1 SEO Configuration Types

Define TypeScript interfaces for SEO metadata in seo.ts:

// src/config/seo.ts
export interface StructuredData {
  "@context": string;
  "@type": string;
  [key: string]: string | number | boolean | object | null;
}
 
export interface LocalBusiness extends StructuredData {
  address: {
    "@type": string;
    streetAddress: string;
    addressLocality: string;
    addressRegion: string;
    postalCode: string;
    addressCountry: string;
  };
  geo: {
    "@type": string;
    latitude: number;
    longitude: number;
  };
  priceRange: string;
  openingHoursSpecification: Array<{
    "@type": string;
    dayOfWeek: string[];
    opens: string;
    closes: string;
  }>;
}
 
export interface MetaData {
  title: string;
  description: string;
  keywords?: string[];
  canonicalUrl?: string;
  ogTitle?: string;
  ogDescription?: string;
  ogImage?: string;
  ogUrl?: string;
  ogType?: "website" | "article" | "profile" | "book";
  twitterCard?: "summary" | "summary_large_image" | "app" | "player";
  twitterTitle?: string;
  twitterDescription?: string;
  twitterImage?: string;
  twitterCreator?: string;
  noIndex?: boolean;
  structuredData?: StructuredData[];
  localBusiness?: boolean;
}
 
export interface PageMeta {
  [path: string]: MetaData;
}

These interfaces define the structure for:

  • General metadata (title, description, keywords)
  • Open Graph metadata for social sharing
  • Twitter Card metadata for Twitter sharing
  • Structured data for rich search results
  • Local business information for local SEO

2.3.2 Location & Business Information

Define location and business information to enhance local SEO:

// Location information
export const locationInfo = {
  streetAddress: "123 Example Street",
  city: "Example City",
  region: "ST",
  postalCode: "12345",
  country: "US",
  phone: "+1 (555) 123-4567",
  email: "contact@example.com",
  // Example coordinates
  latitude: 40.7128,
  longitude: -74.006,
  nearbyCities: ["City1", "City2", "City3", "City4", "City5"],
  localKeywords: [
    "Region Name",
    "County Name",
    "City Name",
    "State Name",
    "Area Name",
    "Local Area",
    "Regional Area",
  ],
  openingHours: [
    {
      days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
      opens: "09:00",
      closes: "17:00",
    },
    {
      days: ["Saturday"],
      opens: "10:00",
      closes: "14:00",
    },
  ],
  priceRange: "$$",
};

2.3.3 Site-Wide Metadata

Define global site metadata:

// basic site information
export const siteMetadata = {
  siteName: "Example Site",
  siteUrl: "https://example.com",
  defaultTitle: "Example Site - Your Default Title",
  defaultDescription:
    "A comprehensive description of your site and its purpose",
  defaultKeywords: [
    ...new Set([
      "keyword1",
      "keyword2",
      "keyword3",
      "service1",
      "service2",
      "service3",
      "support",
      "customer service",
      "virtual assistant",
      "message taking",
      "appointment scheduling",
      "emergency response",
      "professional service",
      "business communication",
      ...locationInfo.localKeywords,
    ]),
  ],
  defaultOgImage: "/og-image.jpg",
  twitterHandle: "@ExampleSite",
  fullAddress: "123 Example Street, Example City, ST 12345",
  formattedPhone: "+1 (555) 123-4567",
  locationString: `Example City, ST and serving the entire Example Region including City1, City2, and City3`,
};

2.3.4 Page-Specific Metadata

Define metadata for each route:

// Generate location-enriched description
const getLocationDescription = (baseDescription: string) => {
  if (baseDescription.includes(locationInfo.city)) {
    return baseDescription;
  }
  return `${baseDescription} Located in ${locationInfo.city}, ${locationInfo.region}, serving the Example Region and surrounding areas.`;
};
 
// metadata configuration for each page
export const pageMeta: PageMeta = {
  "/": {
    title: `${siteMetadata.defaultTitle} | ${siteMetadata.siteName} | ${locationInfo.city}, ${locationInfo.region}`,
    description: getLocationDescription(siteMetadata.defaultDescription),
    keywords: [
      ...siteMetadata.defaultKeywords,
      "local business",
      "small business support",
      ...locationInfo.localKeywords,
    ],
    ogImage: siteMetadata.defaultOgImage,
    ogType: "website",
    canonicalUrl: siteMetadata.siteUrl,
    localBusiness: true,
  },
  "/about": {
    title: `About Us | ${siteMetadata.siteName} | ${locationInfo.city}, ${locationInfo.region}`,
    description: getLocationDescription("Learn about our company and mission"),
    keywords: [
      ...siteMetadata.defaultKeywords,
      "about us",
      "company history",
      "mission",
      "vision",
      ...locationInfo.localKeywords,
      "regional business history",
    ],
    ogImage: "/about-og-image.jpg",
    ogType: "website",
    canonicalUrl: `${siteMetadata.siteUrl}/about`,
    localBusiness: true,
  },
  // Additional page metadata definitions...
};

2.3.5 Metadata Helper Functions

Create helper functions for metadata retrieval and structured data generation:

// Helper function to get metadata for current path
export function getMetadata(path: string): MetaData {
  // Strip query parameters if any
  const cleanPath = path.split("?")[0];
 
  return (
    pageMeta[cleanPath] || {
      title: `${siteMetadata.defaultTitle} | ${siteMetadata.siteName} | ${locationInfo.city}, ${locationInfo.region}`,
      description: getLocationDescription(siteMetadata.defaultDescription),
      keywords: [
        ...siteMetadata.defaultKeywords,
        ...locationInfo.localKeywords,
      ],
      ogImage: siteMetadata.defaultOgImage,
      canonicalUrl: `${siteMetadata.siteUrl}${cleanPath}`,
    }
  );
}
 
// Generate LocalBusiness structured data
export function generateLocalBusinessStructuredData(): LocalBusiness {
  return {
    "@context": "https://schema.org",
    "@type": "LocalBusiness",
    name: siteMetadata.siteName,
    description: siteMetadata.defaultDescription,
    url: siteMetadata.siteUrl,
    telephone: locationInfo.phone,
    email: locationInfo.email,
    logo: `${siteMetadata.siteUrl}/logo.png`,
    image: [
      `${siteMetadata.siteUrl}/images/business-exterior.jpg`,
      `${siteMetadata.siteUrl}/images/office.jpg`,
      `${siteMetadata.siteUrl}/images/team.jpg`,
    ],
    priceRange: locationInfo.priceRange,
    address: {
      "@type": "PostalAddress",
      streetAddress: locationInfo.streetAddress,
      addressLocality: locationInfo.city,
      addressRegion: locationInfo.region,
      postalCode: locationInfo.postalCode,
      addressCountry: locationInfo.country,
    },
    geo: {
      "@type": "GeoCoordinates",
      latitude: locationInfo.latitude,
      longitude: locationInfo.longitude,
    },
    openingHoursSpecification: locationInfo.openingHours.map((hours) => ({
      "@type": "OpeningHoursSpecification",
      dayOfWeek: hours.days,
      opens: hours.opens,
      closes: hours.closes,
    })),
    sameAs: [
      "https://www.facebook.com/example",
      "https://twitter.com/ExampleSite",
      "https://www.linkedin.com/company/example-company",
      "https://www.yelp.com/biz/example-business",
      "https://www.google.com/maps?cid=123456789",
    ],
    areaServed: [
      {
        "@type": "City",
        name: locationInfo.city,
        sameAs: `https://en.wikipedia.org/wiki/${locationInfo.city}`,
      },
      {
        "@type": "City",
        name: locationInfo.nearbyCities[0],
        sameAs: `https://en.wikipedia.org/wiki/${locationInfo.nearbyCities[0]}`,
      },
      // Additional service areas...
    ],
    hasOfferCatalog: {
      "@type": "OfferCatalog",
      name: "Services",
      itemListElement: [
        {
          "@type": "Offer",
          itemOffered: {
            "@type": "Service",
            name: "24/7 Service",
            description:
              "Professional services available 24 hours a day, 7 days a week.",
          },
        },
        // Additional services...
      ],
    },
  };
}
 
// Generate Organization structured data
export function generateBusinessStructuredData(): StructuredData {
  return {
    "@context": "https://schema.org",
    "@type": "Organization",
    name: siteMetadata.siteName,
    url: siteMetadata.siteUrl,
    logo: `${siteMetadata.siteUrl}/logo.png`,
    description: siteMetadata.defaultDescription,
    address: {
      "@type": "PostalAddress",
      streetAddress: locationInfo.streetAddress,
      addressLocality: locationInfo.city,
      addressRegion: locationInfo.region,
      postalCode: locationInfo.postalCode,
      addressCountry: locationInfo.country,
    },
    contactPoint: {
      "@type": "ContactPoint",
      telephone: locationInfo.phone,
      contactType: "customer service",
      availableLanguage: ["English"],
      areaServed: "US",
      contactOption: "TollFree",
    },
    sameAs: [
      "https://www.facebook.com/example",
      "https://twitter.com/ExampleSite",
      "https://www.linkedin.com/company/example-company",
      "https://www.yelp.com/biz/example-business",
      "https://www.google.com/maps?cid=123456789",
    ],
  };
}
 
// Generate breadcrumbs structured data
export function generateBreadcrumbsData(path: string): StructuredData {
  const pathSegments = path.split("/").filter((segment) => segment);
  const breadcrumbList = [
    {
      "@type": "ListItem",
      position: 1,
      name: "Home",
      item: siteMetadata.siteUrl,
    },
  ];
 
  let currentPath = "";
 
  // Add each path segment to the breadcrumb list
  pathSegments.forEach((segment, index) => {
    currentPath += `/${segment}`;
 
    // Try to get a nice title from pageMeta, otherwise capitalize the segment
    const pageMetaEntry = pageMeta[currentPath];
    const name = pageMetaEntry
      ? pageMetaEntry.title.split("|")[0].trim()
      : segment.charAt(0).toUpperCase() + segment.slice(1);
 
    breadcrumbList.push({
      "@type": "ListItem",
      position: index + 2,
      name,
      item: `${siteMetadata.siteUrl}${currentPath}`,
    });
  });
 
  return {
    "@context": "https://schema.org",
    "@type": "BreadcrumbList",
    itemListElement: breadcrumbList,
  };
}

2.4 React Component Implementation

2.4.1 SEO Component

Create a dedicated SEO component to handle metadata:

// src/components/SEO.tsx
import React from "react";
import { Helmet } from "react-helmet-async";
import { MetaData, siteMetadata } from "@/config/seo";
 
interface SEOProps {
  metadata: MetaData;
}
 
const SEO: React.FC<SEOProps> = ({ metadata }) => {
  const {
    title,
    description,
    keywords,
    canonicalUrl,
    ogTitle,
    ogDescription,
    ogImage,
    ogUrl,
    ogType = "website",
    twitterCard = "summary_large_image",
    twitterTitle,
    twitterDescription,
    twitterImage,
    twitterCreator = siteMetadata.twitterHandle,
    noIndex = false,
    structuredData = [],
  } = metadata;
 
  // Safely handle URL construction without using window
  const getFullUrl = (path: string) => {
    if (path.startsWith("http")) return path;
    return `${siteMetadata.siteUrl}${path.startsWith("/") ? path : `/${path}`}`;
  };
 
  // Default URL if none provided
  const pageUrl = ogUrl || canonicalUrl || `${siteMetadata.siteUrl}/`;
 
  // Ensure we have real content for title
  const finalTitle = title || siteMetadata.defaultTitle;
  const finalDescription = description || siteMetadata.defaultDescription;
 
  // Log for debugging during SSR
  if (typeof window === "undefined") {
    console.log(`[SEO Component] Rendering SEO for title: ${finalTitle}`);
  }
 
  return (
    <Helmet>
      {/* Standard metadata tags */}
      <title>{finalTitle}</title>
      <meta name="description" content={finalDescription} />
      {keywords && keywords.length > 0 && (
        <meta name="keywords" content={keywords.join(", ")} />
      )}
      {canonicalUrl && <link rel="canonical" href={canonicalUrl} />}
 
      {/* OpenGraph tags */}
      <meta property="og:title" content={ogTitle || finalTitle} />
      <meta
        property="og:description"
        content={ogDescription || finalDescription}
      />
      {ogImage && <meta property="og:image" content={getFullUrl(ogImage)} />}
      <meta property="og:url" content={pageUrl} />
      <meta property="og:type" content={ogType} />
      <meta property="og:site_name" content={siteMetadata.siteName} />
 
      {/* Twitter tags */}
      <meta name="twitter:card" content={twitterCard} />
      <meta
        name="twitter:title"
        content={twitterTitle || ogTitle || finalTitle}
      />
      <meta
        name="twitter:description"
        content={twitterDescription || ogDescription || finalDescription}
      />
      {twitterImage && (
        <meta name="twitter:image" content={getFullUrl(twitterImage)} />
      )}
      {twitterCreator && (
        <meta name="twitter:creator" content={twitterCreator} />
      )}
 
      {/* Indexing control */}
      {noIndex && <meta name="robots" content="noindex, nofollow" />}
 
      {/* Structured data JSON-LD */}
      {structuredData.map((data, index) => (
        <script
          key={`structured-data-${index}`}
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
        />
      ))}
    </Helmet>
  );
};
 
export default SEO;

This component:

  • Uses React Helmet to modify document head
  • Handles all types of metadata (standard, Open Graph, Twitter)
  • Properly formats URLs for social sharing
  • Injects structured data (JSON-LD) for rich search results

2.4.2 Main App Component

Implement the main App component with route-aware SEO:

// src/App.tsx
import { Toaster } from "@/components/ui/cn/toaster";
import { Toaster as Sonner } from "@/components/ui/cn/sonner";
import { TooltipProvider } from "@/components/ui/cn/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Routes, Route, useLocation } from "react-router-dom";
import { useState, useEffect } from "react";
import Navbar from "@/components/layout/Navbar";
import Index from "./pages/Index";
import ChatBot from "@/components/layout/ChatBot";
import { generateBusinessStructuredData, getMetadata } from "@/config/seo";
import SEO from "./components/SEO";
 
const queryClient = new QueryClient();
 
// PageSEO component to handle page-specific SEO
const PageSEO = () => {
  const location = useLocation();
  const metadata = getMetadata(location.pathname);
 
  // Log for debugging during SSR
  if (typeof window === "undefined") {
    console.log(`[PageSEO] Rendering for path: ${location.pathname}`);
    console.log(`[PageSEO] Title: ${metadata.title}`);
  }
 
  return <SEO metadata={metadata} />;
};
 
// Base SEO component that includes site-wide structured data
const BaseSEO = () => {
  // Log for debugging during SSR
  if (typeof window === "undefined") {
    console.log("[BaseSEO] Rendering base SEO data");
  }
 
  return (
    <SEO
      metadata={{
        title: siteMetadata.siteName,
        description: siteMetadata.defaultDescription,
        structuredData: [generateBusinessStructuredData()],
      }}
    />
  );
};
 
// ClientOnlyComponents renders browser-only components
const ClientOnlyComponents = () => {
  const [isMounted, setIsMounted] = useState(false);
 
  useEffect(() => {
    setIsMounted(true);
  }, []);
 
  if (!isMounted) {
    return null;
  }
 
  return (
    <>
      <Toaster />
      <Sonner />
      <ChatBot />
    </>
  );
};
 
// Main application routes
const AppRoutes = () => {
  return (
    <>
      <BaseSEO />
      <PageSEO />
      <Navbar />
      <Routes>
        <Route path="/" element={<Index />} />
        <Route path="/page1" element={<Page1 />} />
        <Route path="/page2" element={<Page2 />} />
        <Route path="/page3" element={<Page3 />} />
        <Route path="/page4" element={<Page4 />} />
        <Route path="/page5" element={<Page5 />} />
        <Route path="*" element={<NotFound />} />
      </Routes>
      <Footer />
    </>
  );
};
 
// Main App component
const App = () => {
  return (
    <QueryClientProvider client={queryClient}>
      <TooltipProvider>
        <ClientOnlyComponents />
        <AppRoutes />
      </TooltipProvider>
    </QueryClientProvider>
  );
};
 
export default App;

The App component includes:

  • Route-specific SEO via PageSEO component
  • Global SEO via BaseSEO component
  • Client-only components that render only in the browser
  • Application routes using React Router

2.5 Static Site Generation

2.5.1 Static Site Generation Script

Implement the static site generation process in generate-static.js:

// scripts/generate-static.js
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
 
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, ".."); // Project root directory
 
// Helper functions
const toAbsolute = (p) => path.resolve(rootDir, p);
const outDir = toAbsolute("dist");
 
/**
 * Processes HTML with Helmet SEO data
 * @param {string} html - Original HTML template
 * @param {object} helmetData - React Helmet data object
 * @returns {string} - Processed HTML with SEO tags
 */
function processHelmetData(html, helmetData) {
  if (!helmetData) {
    console.warn("No Helmet data provided to process");
    return html;
  }
 
  try {
    // Extract head section
    const headOpenIndex = html.indexOf("<head");
    const headCloseIndex = html.indexOf("</head>", headOpenIndex);
 
    if (headOpenIndex === -1 || headCloseIndex === -1) {
      console.error("Could not find head tags in HTML template");
      return html;
    }
 
    // Get the head content
    const headStartContentIndex = html.indexOf(">", headOpenIndex) + 1;
    const headContent = html.substring(headStartContentIndex, headCloseIndex);
 
    // Process head content: Remove default title tag to avoid duplicates
    let processedHeadContent = headContent.replace(
      /<title[^>]*>.*?<\/title>/gi,
      ""
    );
 
    // Create new head content with Helmet data at the beginning
    const helmetTags = [
      helmetData.title?.toString() || "",
      helmetData.meta?.toString() || "",
      helmetData.link?.toString() || "",
      helmetData.script?.toString() || "",
      helmetData.style?.toString() || "",
      helmetData.noscript?.toString() || "",
      helmetData.base?.toString() || "",
    ].join("\n");
 
    // Log SEO content for debugging
    console.log(
      `[SSG] Adding SEO tags: ${helmetTags.split("\n").length} tag(s)`
    );
 
    // Add Helmet tags at the beginning of the head content
    processedHeadContent = helmetTags + processedHeadContent;
 
    // Replace the head content in the HTML
    return (
      html.substring(0, headStartContentIndex) +
      processedHeadContent +
      html.substring(headCloseIndex)
    );
  } catch (error) {
    console.error("Error processing Helmet data:", error);
    return html;
  }
}
 
// Pre-render all routes
(async function () {
  try {
    console.log("Starting static site generation...");
 
    // Dynamically import routes relative to project root
    const { routes } = await import("../src/routes.js");
 
    // Check if server build exists
    const serverEntryPath = toAbsolute("dist/server/entry-server.js");
    if (!fs.existsSync(serverEntryPath)) {
      console.error(`Server entry file not found: ${serverEntryPath}`);
      console.error('Please run "npm run build:server" first');
      process.exit(1);
    }
 
    // Import server-side render function using file path
    const serverEntryUrl = `file://${serverEntryPath}`;
    const { render } = await import(serverEntryUrl);
 
    // Read the client-side build HTML template
    const templatePath = toAbsolute("dist/index.html");
    if (!fs.existsSync(templatePath)) {
      console.error(`Client template file not found: ${templatePath}`);
      console.error('Please run "npm run build:client" first');
      process.exit(1);
    }
    const template = fs.readFileSync(templatePath, "utf-8");
 
    // Find the root element where our app should be rendered
    const appRootTag = '<div id="root">';
    const closingRootTag = "</div>";
    const ssrOutlet = "<!--ssr-outlet-->";
 
    // Pre-render each route
    for (const url of routes) {
      // Handle wildcard route for 404 page
      if (url === "*") {
        try {
          // Render the 404 page
          const { html: renderedApp, helmetData } = render("/404");
 
          // Process the HTML template with SEO data
          let notFoundHtml = template;
 
          // Add SEO data if available
          if (helmetData) {
            console.log(`[SSG] Processing SEO data for 404 page`);
            notFoundHtml = processHelmetData(notFoundHtml, helmetData);
          } else {
            console.warn(`[SSG] No SEO data for 404 page`);
          }
 
          // Replace the SSR outlet or root div content with server-rendered HTML
          if (notFoundHtml.includes(ssrOutlet)) {
            notFoundHtml = notFoundHtml.replace(ssrOutlet, renderedApp);
          } else {
            // Fallback: Insert between root tags
            const startPos = notFoundHtml.indexOf(appRootTag);
            if (startPos === -1) {
              console.error(`Could not find '${appRootTag}' in template`);
              continue;
            }
            const startInsertPos = startPos + appRootTag.length;
            const endPos = notFoundHtml.indexOf(closingRootTag, startInsertPos);
            if (endPos === -1) {
              console.error(
                `Could not find closing '${closingRootTag}' after '${appRootTag}'`
              );
              continue;
            }
 
            // Insert the rendered app content between the root tags
            notFoundHtml =
              notFoundHtml.substring(0, startInsertPos) +
              renderedApp +
              notFoundHtml.substring(endPos);
          }
 
          fs.writeFileSync(toAbsolute("dist/404.html"), notFoundHtml);
          console.log("Pre-rendered: /404 -> dist/404.html");
        } catch (err) {
          console.error(`Error rendering 404 page: ${err.message || err}`);
          console.error(err.stack);
        }
        continue;
      }
 
      try {
        // Determine output path relative to dist
        const pathWithoutLeadingSlash = url.substring(1); // remove leading '/'
        const filename =
          pathWithoutLeadingSlash === ""
            ? "index.html"
            : `${pathWithoutLeadingSlash}/index.html`;
        const filePath = path.join(outDir, filename);
 
        // Create directory if needed
        const dirname = path.dirname(filePath);
        if (!fs.existsSync(dirname)) {
          fs.mkdirSync(dirname, { recursive: true });
        }
 
        // Render the page content with SEO data
        console.log(`[SSG] Rendering route: ${url}`);
        const { html: renderedApp, helmetData } = render(url);
 
        // Process HTML with Helmet data
        let processedHtml = template;
 
        if (helmetData) {
          console.log(`[SSG] Processing SEO data for route: ${url}`);
          processedHtml = processHelmetData(processedHtml, helmetData);
 
          if (helmetData.title) {
            console.log(
              `[SSG] Title for ${url}: ${helmetData.title.toString()}`
            );
          }
        } else {
          console.warn(`[SSG] No SEO data available for route: ${url}`);
        }
 
        // Insert rendered app content
        if (processedHtml.includes(ssrOutlet)) {
          processedHtml = processedHtml.replace(ssrOutlet, renderedApp);
        } else {
          // Find position between root tags
          const startPos = processedHtml.indexOf(appRootTag);
          if (startPos === -1) {
            console.error(`Could not find '${appRootTag}' in template`);
            continue;
          }
          const startInsertPos = startPos + appRootTag.length;
          const endPos = processedHtml.indexOf(closingRootTag, startInsertPos);
          if (endPos === -1) {
            console.error(
              `Could not find closing '${closingRootTag}' after '${appRootTag}'`
            );
            continue;
          }
 
          // Insert the rendered app content between the root tags
          processedHtml =
            processedHtml.substring(0, startInsertPos) +
            renderedApp +
            processedHtml.substring(endPos);
        }
 
        // Write the file
        fs.writeFileSync(filePath, processedHtml);
        console.log(`[SSG] Pre-rendered: ${url} -> dist/${filename}`);
      } catch (error) {
        console.error(`Error pre-rendering ${url}: ${error.message || error}`);
        console.error(error.stack);
      }
    }
 
    console.log("Static site generation complete!");
  } catch (error) {
    console.error("Static site generation failed:", error);
    console.error(error.stack);
    process.exit(1);
  }
})();

3. Understanding the Underlying Principles

3.1 Static Site Generation (SSG) Principles

The Static Site Generation process follows these key principles:

  1. Pre-rendering at build time: Instead of rendering content on each request, all HTML is generated during the build process.
  2. Route-based generation: Each defined route is processed independently to create a separate HTML file.
  3. HTML + Data splitting: The process separates HTML structure (from server rendering) from SEO data (from Helmet).
  4. HTML injection: Server-rendered content is inserted into a template HTML file at a specific marker.
  5. Metadata injection: SEO tags are injected into the document head.
  6. Directory structure mirroring: Output file paths match the URL structure of the application.

The implementation code has several key sections:

// Dynamic imports of required modules
const { routes } = await import("../src/routes.js");
const { render } = await import(serverEntryUrl);
 
// Render each route
for (const url of routes) {
  // Render the app with the current URL
  const { html: renderedApp, helmetData } = render(url);
 
  // Process HTML with SEO data
  let processedHtml = processHelmetData(template, helmetData);
 
  // Insert app HTML into the template
  processedHtml = processedHtml.replace(ssrOutlet, renderedApp);
 
  // Write to the filesystem with directory structure matching routes
  const filename =
    pathWithoutLeadingSlash === ""
      ? "index.html"
      : `${pathWithoutLeadingSlash}/index.html`;
  fs.writeFileSync(filePath, processedHtml);
}

This approach has significant advantages:

  • Search engines can index fully-rendered content without executing JavaScript
  • Initial page load is faster as HTML is already complete
  • Reduced server load as pages are pre-rendered
  • Works perfectly for content that doesn't change frequently

3.2 Hydration Principles

Hydration is the process of making server-rendered HTML interactive. The concept is fundamental to modern React applications with SSR/SSG:

  1. Initial static HTML: Server generates complete HTML structure with all visual elements.
  2. JavaScript loading: Client loads JavaScript bundles that contain component logic.
  3. DOM reconciliation: React compares the existing DOM with its virtual representation.
  4. Event listeners attachment: React adds event handlers to make elements interactive.
  5. State initialization: Component state is initialized to match the pre-rendered UI.

Our implementation uses React's dedicated hydration API:

// Use hydrateRoot instead of createRoot
hydrateRoot(
  rootElement,
  <React.StrictMode>
    <BrowserRouter>
      <HelmetProvider>
        <ScrollToAnchor />
        <App />
      </HelmetProvider>
    </BrowserRouter>
  </React.StrictMode>
);

Key differences between hydration and normal rendering:

  • Regular rendering (createRoot): Builds DOM from scratch, replacing any existing content
  • Hydration (hydrateRoot): Preserves existing DOM, only attaching event listeners

Hydration requirements:

  • Server-rendered HTML must exactly match React's expected output
  • Component structure must be identical between server and client
  • Initial props and state must produce the same visual output

Handling client-only components:

// ClientOnlyComponents renders browser-only components
const ClientOnlyComponents = () => {
  const [isMounted, setIsMounted] = useState(false);
 
  useEffect(() => {
    setIsMounted(true);
  }, []);
 
  if (!isMounted) {
    return null;
  }
 
  return (
    <>
      <Toaster />
      <Sonner />
      <ChatBot />
    </>
  );
};

This pattern prevents hydration mismatches for components that should only render in the browser.

3.3 React Helmet and SEO Principles

React Helmet is a document head manager for React that allows components to define their own metadata. Its core principles include:

  1. Component-based metadata: Each component can define its own document head requirements.
  2. Nested components: Metadata from child components can override parent components.
  3. Side-effect management: Helmet handles adding, updating, and removing tags from <head>.
  4. Server rendering support: Helmet can capture metadata during server rendering.

Our SEO component demonstrates these principles:

const SEO: React.FC<SEOProps> = ({ metadata }) => {
  // Extract metadata properties with defaults
  const {
    title,
    description,
    keywords,
    canonicalUrl,
    // ...other properties
  } = metadata;
 
  return (
    <Helmet>
      {/* Standard metadata tags */}
      <title>{finalTitle}</title>
      <meta name="description" content={finalDescription} />
 
      {/* OpenGraph tags */}
      <meta property="og:title" content={ogTitle || finalTitle} />
      <meta
        property="og:description"
        content={ogDescription || finalDescription}
      />
 
      {/* Structured data JSON-LD */}
      {structuredData.map((data, index) => (
        <script
          key={`structured-data-${index}`}
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
        />
      ))}
    </Helmet>
  );
};

During server rendering, we capture this metadata:

export function render(url: string) {
  // Create a HelmetContext to capture SEO data
  const helmetContext: { helmet?: HelmetServerState } = {};
 
  // Render React components to HTML string
  const appHtml = renderToString(
    <StaticRouter location={url}>
      <HelmetProvider context={helmetContext}>
        <App />
      </HelmetProvider>
    </StaticRouter>
  );
 
  // Get the Helmet data for SEO
  const { helmet } = helmetContext;
 
  return {
    html: appHtml,
    helmetData: helmet,
  };
}

During static generation, this metadata is processed and inserted into the HTML template:

function processHelmetData(html, helmetData) {
  // ...processing logic
 
  // Create new head content with Helmet data at the beginning
  const helmetTags = [
    helmetData.title?.toString() || "",
    helmetData.meta?.toString() || "",
    helmetData.link?.toString() || "",
    helmetData.script?.toString() || "",
    helmetData.style?.toString() || "",
    helmetData.noscript?.toString() || "",
    helmetData.base?.toString() || "",
  ].join("\n");
 
  // Add Helmet tags at the beginning of the head content
  processedHeadContent = helmetTags + processedHeadContent;
 
  // ...html reassembly
}

3.4 Schema.org Structured Data

Structured data is a standardized format for providing information about a page and classifying its content. Using JSON-LD (JavaScript Object Notation for Linked Data), we implement several Schema.org types:

  1. Organization: General business information
  2. LocalBusiness: Location-specific business details
  3. BreadcrumbList: Navigation hierarchy information

Implementation for a local business:

export function generateLocalBusinessStructuredData(): LocalBusiness {
  return {
    "@context": "https://schema.org",
    "@type": "LocalBusiness",
    name: siteMetadata.siteName,
    description: siteMetadata.defaultDescription,
    url: siteMetadata.siteUrl,
    telephone: locationInfo.phone,
    email: locationInfo.email,
    // Location information
    address: {
      "@type": "PostalAddress",
      streetAddress: locationInfo.streetAddress,
      addressLocality: locationInfo.city,
      addressRegion: locationInfo.region,
      postalCode: locationInfo.postalCode,
      addressCountry: locationInfo.country,
    },
    geo: {
      "@type": "GeoCoordinates",
      latitude: locationInfo.latitude,
      longitude: locationInfo.longitude,
    },
    // Business hours
    openingHoursSpecification: locationInfo.openingHours.map((hours) => ({
      "@type": "OpeningHoursSpecification",
      dayOfWeek: hours.days,
      opens: hours.opens,
      closes: hours.closes,
    })),
    // Service information
    hasOfferCatalog: {
      "@type": "OfferCatalog",
      name: "Services",
      itemListElement: [
        {
          "@type": "Offer",
          itemOffered: {
            "@type": "Service",
            name: "24/7 Service",
            description:
              "Professional services available 24 hours a day, 7 days a week.",
          },
        },
        // Additional services...
      ],
    },
  };
}

These structured data objects are serialized to JSON and included in the page as <script type="application/ld+json"> tags. Search engines use this information to:

  • Create rich results in search listings
  • Power knowledge panels and answer boxes
  • Provide contextual information about businesses
  • Improve local search presence

4. Overall Process and Architecture Analysis

4.1 Complete Build Process

The complete build process integrates all the components described above:

  1. Development: npm run dev - Uses Vite's development server with hot module replacement
  2. Production Build: npm run build - Three-step process:
    • build:client: Generates client-side JavaScript and CSS bundles
    • build:server: Compiles server-side rendering code
    • generate: Runs static site generation to create HTML files
"scripts": {
  "dev": "vite",
  "build": "npm run build:client && npm run build:server && npm run generate",
  "build:client": "vite build --outDir dist",
  "build:server": "vite build --outDir dist/server --ssr src/entry-server.tsx",
  "generate": "node --experimental-json-modules scripts/generate-static.js",
  "preview": "vite preview"
}

The output of this process is a complete static site with:

  • Pre-rendered HTML for each route
  • Complete SEO metadata in each HTML file
  • Client-side JavaScript for interactivity
  • Structured data for search engines

4.2 Data Flow Architecture

The overall data flow in the application follows this pattern:

  1. Configuration: SEO metadata is defined in configuration objects
  2. Component Rendering: React components use this metadata to generate HTML and Helmet data
  3. Server Rendering: Components are rendered to HTML strings, Helmet data is captured
  4. Static Generation: HTML and Helmet data are combined into static files
  5. Client Hydration: Static HTML becomes interactive in the browser

Key architectural principles:

  • Separation of concerns: Content, metadata, and rendering logic are separated
  • Progressive enhancement: Site works without JavaScript but becomes interactive when JS loads
  • Data-driven SEO: SEO metadata is derived from structured configuration objects
  • Fallback mechanisms: Default values ensure complete metadata even for missing configurations

4.3 Technical Considerations and Optimizations

4.3.1 Environment Detection

The implementation uses environment detection to handle differences between server and browser:

// Server-only code
if (typeof window === "undefined") {
  // Server-specific logic
  (React as any).useLayoutEffect = React.useEffect;
  console.log(`[SSR] Rendered URL: ${url}`);
}
 
// Client-only components
const ClientOnlyComponents = () => {
  const [isMounted, setIsMounted] = useState(false);
  useEffect(() => {
    setIsMounted(true);
  }, []);
  if (!isMounted) return null;
  // ...
};

4.3.2 Error Handling

Robust error handling prevents build failures:

try {
  // Render the page content with SEO data
  const { html: renderedApp, helmetData } = render(url);
  // Process and write to file
} catch (error) {
  console.error(`Error pre-rendering ${url}: ${error.message || error}`);
  console.error(error.stack);
  // Continue with next route instead of failing entirely
}

4.3.3 Path Handling

Careful path handling ensures correct file structure:

// Determine output path relative to dist
const pathWithoutLeadingSlash = url.substring(1);
const filename =
  pathWithoutLeadingSlash === ""
    ? "index.html"
    : `${pathWithoutLeadingSlash}/index.html`;
const filePath = path.join(outDir, filename);
 
// Create directory if needed
const dirname = path.dirname(filePath);
if (!fs.existsSync(dirname)) {
  fs.mkdirSync(dirname, { recursive: true });
}

4.3.4 Special Route Handling

Special routes like 404 pages receive custom handling:

// Handle wildcard route for 404 page
if (url === "*") {
  // Render the 404 page
  const { html: renderedApp, helmetData } = render("/404");
  // Process and write to 404.html file
}

4.3.5 SEO Content Generation

Dynamic SEO content improves location-based SEO:

// Generate location-enriched description
const getLocationDescription = (baseDescription: string) => {
  if (baseDescription.includes(locationInfo.city)) {
    return baseDescription;
  }
  return `${baseDescription} Located in ${locationInfo.city}, ${locationInfo.region}, serving the Example Region and surrounding areas.`;
};

5. Conclusion and Best Practices

5.1 Key SEO Implementation Principles

  1. Pre-rendered Content: Generate complete HTML at build time for optimal search engine crawling
  2. Route-Specific Metadata: Define unique metadata for each page/route
  3. Structured Data Integration: Include machine-readable context about page content
  4. Metadata Hierarchy: Use a fallback system to ensure complete metadata
  5. Semantic HTML: Ensure HTML structure provides clear content hierarchy
  6. Client Hydration: Make pre-rendered content interactive without rebuilding the DOM
  7. Environment Awareness: Handle differences between server and browser environments
  8. Error Resilience: Implement robust error handling throughout the build process

5.2 Implementation Best Practices

Based on this implementation, we recommend these best practices:

  1. Separate Entry Points: Use different entry files for client and server code
  2. Component-Based SEO: Encapsulate SEO logic in reusable components
  3. Configuration-Driven Metadata: Store metadata in structured configuration objects
  4. Local SEO Enhancement: Include location-specific information when relevant
  5. Progressive Enhancement: Ensure content is accessible without JavaScript
  6. Structured Data Types: Use appropriate Schema.org types for your content
  7. Client/Server Separation: Clearly distinguish between server-only and client-only code
  8. Fallback Mechanisms: Provide defaults for all metadata fields
  9. Modular Build Process: Break the build into logical, sequential steps
  10. Debug Logging: Include detailed logs to troubleshoot SEO and build issues

By implementing these principles and best practices, you can create React applications that provide excellent user experiences while also performing optimally for search engines and social media platforms.