A reusable, fully generic SEO pattern for Vite + React SPAs. Drop it into any project and configure with your own values.
Client-rendered SPAs have a well-known SEO problem: the initial HTML is an empty shell. Search crawlers and social preview bots that don't execute JavaScript see nothing. This template provides a three-layer SEO system that ensures:
- Build-time: Homepage SEO tags are injected into the raw
index.htmlso crawlers see them immediately. - Runtime: Per-route
<title>, meta, and JSON-LD update dynamically when users navigate client-side. - Discovery:
robots.txt,sitemap.xml,llms.txt, and web app manifest help crawlers find and understand your site.
Copy everything from this template into your Vite + React project root:
your-project/
├── site-seo.mjs ← single source of truth
├── vite-plugin-site-seo.mjs ← build-time SEO injection
├── vite.config.js ← merge plugin into your config
├── index.html ← clean shell (merge with yours)
├── public/
│ ├── robots.txt
│ ├── llms.txt
│ ├── llms-full.txt
│ └── site.webmanifest
└── src/
├── main.jsx ← wrap in HelmetProvider
├── seo/
│ └── config.js ← re-export barrel
└── components/
└── seo/
├── RouteSeo.jsx
└── NotFoundSeo.jsx
npm install react-helmet-async react-router-domOpen site-seo.mjs and replace every YOUR_* placeholder with your actual values. See the Configuration Guide below for details.
Mount RouteSeo once on your main layout and NotFoundSeo on your catch-all route:
import { Outlet, useLocation } from "react-router-dom";
import RouteSeo from "@/components/seo/RouteSeo";
function AppShell() {
return (
<>
<RouteSeo />
<Outlet />
</>
);
}site-seo.mjs (constants)
├──→ src/seo/config.js (re-export barrel)
│ └──→ RouteSeo.jsx / NotFoundSeo.jsx (client-side Helmet)
└──→ vite-plugin-site-seo.mjs (build-time injection + sitemap)
└──→ vite.config.js (plugin registration)
└──→ index.html (tags injected at build)
| Layer | When | What |
|---|---|---|
| Data | Always | site-seo.mjs — all SEO constants, keywords, routes |
| Build | vite build / vite dev |
Plugin injects homepage SEO into index.html + writes sitemap.xml |
| Runtime | Client navigation | RouteSeo updates <head> per route via react-helmet-async |
| Export | Type | Example | Required? |
|---|---|---|---|
SITE_ORIGIN |
string |
"https://example.com" |
Yes |
SITE_NAME |
string |
"Acme Corp" |
Yes |
SITE_SHORT_NAME |
string |
"Acme" |
Recommended |
ORG_SLOGAN |
string |
"Innovation delivered" |
Recommended |
SITE_LANGUAGE |
string |
"en", "en-US", "ar" |
Yes |
SITE_AUTHOR |
string |
"Acme Corp" |
Recommended |
| Export | Type | Example | Required? |
|---|---|---|---|
DEFAULT_OG_IMAGE |
string |
"https://example.com/og-image.png" |
Yes |
OG_IMAGE_WIDTH |
number |
1200 |
Yes |
OG_IMAGE_HEIGHT |
number |
630 |
Yes |
SITE_LOGO_URL |
string |
"https://example.com/logo.png" |
Yes |
| Export | Type | Example | Required? |
|---|---|---|---|
SITE_KEYWORDS |
string[] |
["brand", "industry", "service"] |
Recommended |
Each route in ROUTE_SEO also accepts a keywords array that merges with SITE_KEYWORDS.
| Export | Type | Example | Required? |
|---|---|---|---|
GEO_REGION |
string |
"US-NY" |
Optional |
GEO_PLACENAME |
string |
"New York" |
Optional |
GEO_POSITION |
string |
"40.7128;-74.0060" |
Optional |
| Export | Type | Example | Required? |
|---|---|---|---|
SOCIAL_PROFILES |
{ platform, url }[] |
See file | Optional |
Populates the sameAs field in Organization JSON-LD.
| Export | Type | Example | Required? |
|---|---|---|---|
CONTACT_PHONE |
string |
"+1-800-555-0100" |
Optional |
CONTACT_EMAIL |
string |
"info@example.com" |
Optional |
| Export | Type | Example | Required? |
|---|---|---|---|
VERIFICATION_GOOGLE |
string |
"abc123..." |
Optional |
VERIFICATION_BING |
string |
"abc123..." |
Optional |
VERIFICATION_PINTEREST |
string |
"abc123..." |
Optional |
| Export | Type | Example | Required? |
|---|---|---|---|
SEARCH_ACTION_URL |
string | null |
"https://example.com/search?q={search_term_string}" |
Optional |
Set to null to disable.
| Export | Type | Required? |
|---|---|---|
ROUTE_SEO |
Record<string, { title, description, keywords?, breadcrumb }> |
Yes |
Keys must match your react-router-dom route paths (no trailing slash).
| Export | Type | Required? |
|---|---|---|
SITEMAP_ENTRIES |
{ path, changefreq, priority }[] |
Yes |
Only include pages that should appear in search results.
<title>and<meta name="description"><meta name="keywords">(global + home route keywords)<meta name="author"><link rel="canonical">- Full Open Graph:
og:title,og:description,og:url,og:image,og:image:width,og:image:height,og:type,og:site_name,og:locale - Full Twitter Card:
twitter:card,twitter:title,twitter:description,twitter:image - Geo meta:
geo.region,geo.placename,geo.position,ICBM - Verification:
google-site-verification,msvalidate.01,p:domain_verify <link rel="manifest">for web app manifest
- Organization: name, URL, description, slogan, logo,
sameAs(social profiles),contactPoint - WebSite: name, URL, publisher,
inLanguage,potentialAction(SearchAction for Sitelinks Searchbox)
<title>,<meta name="description">,<meta name="keywords"><link rel="canonical">(full URL per route)- Open Graph + Twitter Card (updated per route)
- WebPage JSON-LD with
isPartOfWebSite - BreadcrumbList JSON-LD for all non-home routes
robots.txt— allows all, points to sitemapsitemap.xml— auto-generated at build timellms.txt— short LLM-readable site summaryllms-full.txt— expanded LLM-readable site documentationsite.webmanifest— PWA web app manifest
- Add the route path to
ROUTE_SEOinsite-seo.mjs:
"/contact": {
title: "YOUR_SITE_NAME | Contact",
description: "Get in touch with our team.",
keywords: ["contact", "support", "get in touch"],
breadcrumb: "Contact",
},- Add it to
SITEMAP_ENTRIES:
{ path: "/contact", changefreq: "monthly", priority: "0.6" },- Add the route to your
react-router-domconfiguration.
That's it — RouteSeo picks it up automatically.
Injected into the homepage index.html at build time:
{
"@type": "Organization",
"name": "YOUR_SITE_NAME",
"url": "https://YOUR_PRODUCTION_DOMAIN/",
"description": "...",
"slogan": "...",
"logo": { "@type": "ImageObject", "url": "..." },
"sameAs": ["https://linkedin.com/...", "https://twitter.com/..."],
"contactPoint": {
"@type": "ContactPoint",
"telephone": "...",
"email": "...",
"contactType": "customer service"
}
}{
"@type": "WebSite",
"name": "YOUR_SITE_NAME",
"url": "https://YOUR_PRODUCTION_DOMAIN/",
"publisher": { "@type": "Organization", "name": "YOUR_SITE_NAME" },
"inLanguage": "en",
"potentialAction": {
"@type": "SearchAction",
"target": "https://YOUR_PRODUCTION_DOMAIN/search?q={search_term_string}",
"query-input": "required name=search_term_string"
}
}Injected at runtime via react-helmet-async:
{
"@type": "WebPage",
"name": "Page Title",
"description": "Page description",
"url": "https://YOUR_PRODUCTION_DOMAIN/page",
"inLanguage": "en",
"keywords": "keyword1, keyword2, keyword3",
"isPartOf": {
"@type": "WebSite",
"name": "YOUR_SITE_NAME",
"url": "https://YOUR_PRODUCTION_DOMAIN/"
}
}{
"@type": "BreadcrumbList",
"itemListElement": [
{ "@type": "ListItem", "position": 1, "name": "Home", "item": "https://YOUR_PRODUCTION_DOMAIN/" },
{ "@type": "ListItem", "position": 2, "name": "About", "item": "https://YOUR_PRODUCTION_DOMAIN/about" }
]
}After configuration and build:
npm run build— should complete without errors- Open
dist/index.html— confirm:<title>matches your home title<meta name="description">is present<meta name="keywords">contains your keywords<link rel="canonical">points to your origin- All
og:*andtwitter:*meta tags are present <meta name="google-site-verification">(if configured)<meta name="geo.*">tags (if configured)<link rel="manifest">points to/site.webmanifest<script type="application/ld+json">contains valid Organization + WebSite
- Open
dist/sitemap.xml— confirm:- All
<loc>URLs match yourSITE_ORIGIN <lastmod>is present- Only indexable routes are listed
- All
npm run dev, load/— inspect<head>in DevTools for injected tags- Client-navigate to
/about(etc.) — confirm document title and meta change - Validate JSON-LD — paste
dist/index.htmlcontent into Google Rich Results Test - Check
robots.txt— confirm sitemap URL uses your production domain
- Non-home URLs: Full per-route tags exist in the DOM after JavaScript runs. Google executes JS, but some bots or social preview fetchers may only see the shell HTML. Homepage injection mitigates this for
/. - Perfect parity for every path in raw HTML requires prerender, SSR, or edge HTML rewriting.
- Keywords meta tag: Google ignores
<meta name="keywords">for ranking. It's included for completeness and other search engines that may still use it.
| File | Purpose |
|---|---|
site-seo.mjs |
Single source of truth — all constants, routes, keywords |
vite-plugin-site-seo.mjs |
Vite plugin — injects SEO tags + generates sitemap |
vite.config.js |
Registers the plugin + @ alias |
index.html |
Clean HTML shell — no duplicate SEO tags |
public/robots.txt |
Crawler instructions |
public/llms.txt |
Short LLM-readable summary |
public/llms-full.txt |
Expanded LLM-readable documentation |
public/site.webmanifest |
PWA manifest |
src/main.jsx |
Wraps app in HelmetProvider |
src/seo/config.js |
Re-export barrel for @/seo/config imports |
src/components/seo/RouteSeo.jsx |
Per-route SEO via Helmet |
src/components/seo/NotFoundSeo.jsx |
404 page SEO (noindex) |