A 2021 junior developer approach to making React SPAs SEO-friendly without frameworks
The Problem | React apps deliver empty HTML to search engines:
<div id="root"></div> <!-- Google sees nothing -->Our Solution | Wrap your React build with server-side templates that inject SEO data:
React Build → Server Template → SEO Magic ✨
cd seoEx
npm install
npm startcd seoFlask
pip install flask
python app.py- Build React normally → Static files in
/static - Convert
index.html→ Server template with SEO variables - Configure routes → SEO data per page in
page_data.js/routes_config.py - Server renders → Meta tags injected before browser receives HTML
page_data.js/routes_config.py- SEO configuration per routeviews/index.ejs/templates/index.html- Template with dynamic meta tagsapp.js/app.py- Server logic
{
"path": "about",
"title": "About Us | Your App",
"description": "Learn more about our team",
"url": "https://yoursite.com/about",
"img": "/static/images/og_about.png"
}Today we'd recommend Next.js, Remix, or React Server Components. But this solution taught us valuable lessons about SEO, server-side rendering, and creative problem-solving.
Picture this: You've just spent months crafting the perfect React application. The animations are smooth, the user experience is flawless, and your components are beautifully architected. You deploy it with pride, expecting the world to discover your creation through Google searches.
Days pass. Weeks pass. Your beautiful application remains invisible to search engines.
This was the reality for countless React developers between 2013 and the late 2010s. We had created these amazing single-page applications (SPAs) that provided desktop-app-like experiences in the browser, but we had unknowingly sacrificed something crucial: Search Engine Optimization (SEO).
The problem was fundamental to how SPAs work:
- Client-Side Rendering: React applications render content in the browser using JavaScript
- Empty HTML Shell: When search engines crawled your site, they saw basically this:
<div id="root"></div>
- No Content to Index: Search engine crawlers (especially in the early days) couldn't execute JavaScript, so they found nothing to index
Libraries like React Helmet emerged to help manage meta tags, but they still required the JavaScript to execute first - a chicken-and-egg problem for SEO.
Instead of fighting against the nature of SPAs or completely rewriting everything with server-side rendering, we found a middle ground. We took our built React applications and wrapped them with server-side templates that could dynamically inject SEO data.
This guide shows you two implementations of this approach:
- seoEx: Using Express.js with EJS templates
- seoFlask: Using Flask with Jinja2 templates
Both solutions follow the same core principle:
1. Build React App → Static Files (HTML, CSS, JS)
2. Extract index.html → Convert to Server Template
3. Create Route Configuration → SEO Data per Route
4. Server Renders Template → Injects SEO Data
5. Browser Receives → Fully SEO-Optimized HTML
- Template Conversion: Transform your React build's
index.htmlinto a server template - Route-Based SEO Data: Define SEO metadata for each route in your application
- Dynamic Meta Tag Injection: Server renders the template with appropriate SEO data before sending to browser
seoEx/
├── app.js # Express server
├── page_data.js # SEO route configuration
├── package.json # Dependencies
├── views/
│ └── index.ejs # Template file
├── static/ # React build output
│ ├── css/
│ ├── js/
│ ├── media/
│ └── ...
├── robots.txt
└── sitemap.xml
npm init -y
npm install express ejs chalk
npm install --save-dev nodemonvar routes_list = [
{
"path": "/",
"title": "Your App | Home",
"description": "Amazing React app with perfect SEO",
"keywords": "react, seo, spa",
"url": "https://yoursite.com",
"type": "website",
"img": "/static/images/og_home.png"
},
{
"path": "about",
"title": "About Us | Your App",
"description": "Learn more about our amazing team",
"keywords": "about, team, company",
"url": "https://yoursite.com/about",
"type": "website",
"img": "/static/images/og_about.png"
}
// Add more routes as needed
];
module.exports = routes_list;var express = require('express');
var routes_list = require('./page_data');
var app = express();
var path = require('path');
var _appDir = path.dirname(require.main.filename);
// Configure EJS and static files
app.set('view engine', 'ejs');
app.use('/static', express.static(`${_appDir}/static`));
app.set('views', path.join(`${_appDir}/views`));
// SEO data retrieval function
const get_page_data = (page_route_name = '') => {
var seo_response = {
"title": "Default Title",
"description": "Default description",
"keywords": "default, keywords",
"url": "https://yoursite.com",
"type": "website",
"img": "/static/media/og_default.png"
};
routes_list.map((r) => {
if (r["path"] === page_route_name.toString()) {
seo_response["title"] = r["title"];
seo_response["description"] = r["description"];
seo_response["keywords"] = r["keywords"];
seo_response["url"] = r["url"];
seo_response["type"] = r["type"];
seo_response["img"] = r["img"];
}
});
return seo_response;
}
// Routes
app.get('/', function(req, res) {
var seo_response = get_page_data();
res.render('index', {seo_data: seo_response});
});
app.get('/:route', function(req, res) {
var seo_response = get_page_data(req.params.route);
res.render('index', {seo_data: seo_response});
});
// Serve locale files
app.get('/locales/:lang/:filename', function(req, res) {
var lang = req.params.lang;
var filename = req.params.filename;
res.sendFile(`${_appDir}/static/locales/${lang}/${filename}`);
});
// Error handling
app.use(function(err, req, res, next) {
console.error(err.stack);
res.status(404).send('Sorry cant find that!');
});
app.listen(8080, () => {
console.log('SEO-optimized React app running on port 8080');
});Take your React build's index.html and modify the meta tags:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="shortcut icon" type="image/x-icon" href="/static/icons/favicon.ico"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<!-- Dynamic SEO Meta Tags -->
<title><%= seo_data.title %></title>
<meta name="description" content="<%= seo_data.description %>">
<meta name="keywords" content="<%= seo_data.keywords %>">
<meta name="author" content="Your Company">
<!-- Open Graph Meta Tags -->
<meta property="og:site_name" content="Your App" data-react-helmet="true">
<meta property="og:title" content="<%= seo_data.title %>" data-react-helmet="true">
<meta property="og:description" content="<%= seo_data.description %>" data-react-helmet="true">
<meta property="og:url" content="<%= seo_data.url %>" data-react-helmet="true">
<meta property="og:type" content="<%= seo_data.type %>" data-react-helmet="true">
<meta property="og:image" content="<%= seo_data.img %>" data-react-helmet="true">
<!-- Twitter Meta Tags -->
<meta property="twitter:card" content="summary_large_image" data-react-helmet="true">
<meta property="twitter:image" content="<%= seo_data.img %>" data-react-helmet="true">
<meta property="twitter:title" content="<%= seo_data.title %>" data-react-helmet="true">
<!-- Your React CSS and other assets -->
<link href="/static/css/main.d4947e51.chunk.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<!-- Your React JavaScript files -->
<script src="/static/js/2.6c2d5a31.chunk.js"></script>
<script src="/static/js/main.c392d289.chunk.js"></script>
</body>
</html>npm start
# or for development
npx nodemon app.jsseoFlask/
├── app.py # Flask application
├── routes_config.py # SEO route configuration
├── templates/
│ └── index.html # Jinja2 template
├── static/ # React build output
│ ├── css/
│ ├── js/
│ └── ...
pip install flaskroutes_list = [
{
"path": "/",
"title": "Your App | Home",
"description": "Amazing React app with perfect SEO",
"keywords": "react, seo, spa",
"url": "https://yoursite.com",
"type": "website",
"img": "/static/images/og_home.png"
},
{
"path": "about",
"title": "About Us | Your App",
"description": "Learn more about our amazing team",
"keywords": "about, team, company",
"url": "https://yoursite.com/about",
"type": "website",
"img": "/static/images/og_about.png"
}
# Add more routes as needed
]from flask import Flask, render_template, send_from_directory
from routes_config import routes_list
app = Flask(__name__)
def get_seo_data(route_path=""):
"""Get SEO data for a specific route"""
default_seo = {
"title": "Default Title",
"description": "Default description",
"keywords": "default, keywords",
"url": "https://yoursite.com",
"type": "website",
"img": "/static/images/og_default.png"
}
for route in routes_list:
if route["path"] == route_path:
return route
return default_seo
@app.route("/")
def index():
seo_data = get_seo_data("")
return render_template('index.html', seo_data=seo_data)
@app.route("/<route>")
def route_handler(route):
seo_data = get_seo_data(route)
return render_template('index.html', seo_data=seo_data)
@app.route("/locales/<lang>/<filename>")
def get_locale_file(lang, filename):
return send_from_directory('static', f'locales/{lang}/{filename}')
if __name__ == "__main__":
app.run("0.0.0.0", port=5000, debug=True)<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<link rel="shortcut icon" type="image/x-icon" href="/static/icons/favicon.ico"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<meta name="theme-color" content="#000000"/>
<!-- Dynamic SEO Meta Tags -->
<title>{{ seo_data.title }}</title>
<meta name="description" content="{{ seo_data.description }}">
<meta name="keywords" content="{{ seo_data.keywords }}">
<meta name="author" content="Your Company">
<!-- Open Graph Meta Tags -->
<meta property="og:site_name" content="Your App">
<meta property="og:title" content="{{ seo_data.title }}">
<meta property="og:description" content="{{ seo_data.description }}">
<meta property="og:url" content="{{ seo_data.url }}">
<meta property="og:type" content="{{ seo_data.type }}">
<meta property="og:image" content="{{ seo_data.img }}">
<!-- Twitter Meta Tags -->
<meta property="twitter:card" content="summary_large_image">
<meta property="twitter:image" content="{{ seo_data.img }}">
<meta property="twitter:title" content="{{ seo_data.title }}">
<!-- Your React CSS -->
<link href="/static/css/main.d4947e51.chunk.css" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<!-- Your React JavaScript -->
<script src="/static/js/2.6c2d5a31.chunk.js"></script>
<script src="/static/js/main.c392d289.chunk.js"></script>
</body>
</html>python app.py# https://www.robotstxt.org/robotstxt.html
User-agent: *
Allow: /
Allow: /about
Allow: /contact
Allow: /products
Disallow: /api
Disallow: /admin
Sitemap: https://yoursite.com/sitemap.xml
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://yoursite.com</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://yoursite.com/about</loc>
<lastmod>2024-01-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<!-- Add more URLs -->
</urlset>- Keep your React SPA experience
- Get full SEO capabilities
- No need to rewrite your entire application
- Meta tags rendered server-side
- Proper Open Graph support
- Twitter Card integration
- Complete HTML content for crawlers
- Easy to add new routes
- Centralized SEO configuration
- Can be integrated into existing React workflows
- Static assets served efficiently
- Only meta tags rendered server-side
- React handles all client-side interactions
| Feature | Express + EJS | Flask + Jinja2 |
|---|---|---|
| Setup Complexity | Medium | Easy |
| Performance | High | Medium-High |
| Template Syntax | <%= %> |
{{ }} |
| Ecosystem | NPM packages | Python packages |
| Learning Curve | Moderate | Gentle |
| Memory Usage | Lower | Slightly Higher |
Choose Express if:
- Your team is JavaScript-focused
- You need maximum performance
- You're already using Node.js
Choose Flask if:
- Your team prefers Python
- You want simpler, cleaner syntax
- You're integrating with Python services
After implementing this solution, something magical happened. Our React applications became visible to search engines while maintaining their smooth, app-like user experience. We had solved the fundamental SEO problem without sacrificing what made SPAs great in the first place.
The best part? When React Server Components and Next.js eventually provided built-in solutions, we had already proven that the hybrid approach worked. This method remains valuable for existing React applications that need SEO optimization without complete rewrites.
This approach taught us that sometimes the best solution isn't about choosing between two technologies - it's about making them work together. By wrapping our client-side React applications with server-side templates, we created SEO-friendly SPAs that satisfied both search engines and users.
Whether you choose Express or Flask, the core principle remains the same: let the server handle SEO, let React handle user experience, and let both technologies do what they do best.
Your React application no longer has to choose between being discoverable and being delightful. It can be both.
