feat(seo): migrate to build-time prerendering with vite-react-ssg

Fixes SeoHead (title/canonical/hreflang/JSON-LD) to render during SSR via
vite-react-ssg's Head component instead of a client-only useEffect, so
crawlers see real tags without executing JS. Also fixes an SSR crash in
useTheme (localStorage access with no window during server render) and
removes now-duplicated static meta tags from index.html.
This commit is contained in:
2026-08-02 10:30:26 +02:00
parent 8dcc5f3405
commit 3b53d82b3d
18 changed files with 2137 additions and 271 deletions
-4
View File
@@ -4,10 +4,6 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ombrora Convert</title>
<meta name="description" content="Convertissez vos fichiers en ligne gratuitement : images, documents, polices et ebooks." />
<meta property="og:site_name" content="Ombrora Convert" />
<meta name="twitter:card" content="summary" />
</head>
<body>
<div id="root"></div>
+1171 -49
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -4,8 +4,8 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "vite-react-ssg dev",
"build": "vite-react-ssg build",
"lint": "oxlint",
"preview": "vite preview"
},
@@ -16,7 +16,8 @@
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-i18next": "^17.0.11",
"react-router-dom": "^6.30.4"
"react-router-dom": "^6.30.4",
"vite-react-ssg": "^0.9.2"
},
"devDependencies": {
"@types/react": "^19.2.17",
-38
View File
@@ -1,38 +0,0 @@
import { useEffect } from 'react';
import { Routes, Route, Navigate, useParams } from 'react-router-dom';
import i18n from './i18n.js';
import { Layout } from './components/Layout.jsx';
import { HomePage } from './pages/HomePage.jsx';
import { NotFound } from './pages/NotFound.jsx';
const SUPPORTED_LANGS = ['fr', 'en'];
function RootRedirect() {
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
return <Navigate to={`/${preferred}/`} replace />;
}
function LangShell() {
const { lang } = useParams();
useEffect(() => {
if (SUPPORTED_LANGS.includes(lang) && i18n.language !== lang) {
i18n.changeLanguage(lang);
}
}, [lang]);
if (!SUPPORTED_LANGS.includes(lang)) return <NotFound />;
return <Layout />;
}
export default function App() {
return (
<Routes>
<Route path="/" element={<RootRedirect />} />
<Route path="/:lang" element={<LangShell />}>
<Route index element={<HomePage />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
);
}
+6
View File
@@ -0,0 +1,6 @@
import { Navigate } from 'react-router-dom';
export function RootRedirect() {
const preferred = navigator.language?.toLowerCase().startsWith('en') ? 'en' : 'fr';
return <Navigate to={`/${preferred}/`} replace />;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import i18n from '../i18n.js';
import { Header } from './Header.jsx';
import { Footer } from './Footer.jsx';
import { useTheme } from '../hooks/useTheme.js';
import '../styles/layout.css';
export function LangLayout({ lang }) {
const { theme, toggleTheme } = useTheme();
useEffect(() => {
if (i18n.language !== lang) {
i18n.changeLanguage(lang);
}
}, [lang]);
return (
<>
<Header theme={theme} onToggleTheme={toggleTheme} />
<main>
<Outlet />
</main>
<Footer />
</>
);
}
+27 -71
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react';
import { Head } from 'vite-react-ssg';
const SITE_URL = 'https://convert.ombrora.com';
const META = {
const DEFAULT_META = {
fr: {
title: 'Ombrora Convert — Conversion de fichiers en ligne',
description:
@@ -15,74 +15,30 @@ const META = {
},
};
function setNameMeta(name, content) {
let el = document.head.querySelector(`meta[name="${name}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute('name', name);
document.head.appendChild(el);
}
el.setAttribute('content', content);
}
export function SeoHead({ lang, path = '', title, description, jsonLd, noindex = false }) {
const resolvedTitle = title ?? DEFAULT_META[lang].title;
const resolvedDescription = description ?? DEFAULT_META[lang].description;
const canonical = `${SITE_URL}/${lang}${path}`;
function setPropertyMeta(property, content) {
let el = document.head.querySelector(`meta[property="${property}"]`);
if (!el) {
el = document.createElement('meta');
el.setAttribute('property', property);
document.head.appendChild(el);
}
el.setAttribute('content', content);
}
function setAlternateLink(hreflang, href) {
const selector = `link[rel="alternate"][hreflang="${hreflang}"]`;
let el = document.head.querySelector(selector);
if (!el) {
el = document.createElement('link');
el.setAttribute('rel', 'alternate');
el.setAttribute('hreflang', hreflang);
document.head.appendChild(el);
}
el.setAttribute('href', href);
}
function setJsonLd(lang) {
const id = 'seo-jsonld';
let script = document.getElementById(id);
if (!script) {
script = document.createElement('script');
script.id = id;
script.type = 'application/ld+json';
document.head.appendChild(script);
}
script.textContent = JSON.stringify({
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'Ombrora Convert',
applicationCategory: 'UtilitiesApplication',
operatingSystem: 'Any',
offers: { '@type': 'Offer', price: '0', priceCurrency: 'EUR' },
inLanguage: lang,
url: `${SITE_URL}/${lang}/`,
});
}
export function SeoHead({ lang }) {
useEffect(() => {
const meta = META[lang] ?? META.fr;
document.title = meta.title;
document.documentElement.lang = lang;
setNameMeta('description', meta.description);
setPropertyMeta('og:title', meta.title);
setPropertyMeta('og:description', meta.description);
setPropertyMeta('og:type', 'website');
setPropertyMeta('og:locale', lang === 'fr' ? 'fr_FR' : 'en_US');
setAlternateLink('fr', `${SITE_URL}/fr/`);
setAlternateLink('en', `${SITE_URL}/en/`);
setAlternateLink('x-default', `${SITE_URL}/fr/`);
setJsonLd(lang);
}, [lang]);
return null;
return (
<Head>
<title>{resolvedTitle}</title>
<meta name="description" content={resolvedDescription} />
{noindex && <meta name="robots" content="noindex" />}
<link rel="canonical" href={canonical} />
<meta property="og:site_name" content="Ombrora Convert" />
<meta property="og:title" content={resolvedTitle} />
<meta property="og:description" content={resolvedDescription} />
<meta property="og:type" content="website" />
<meta property="og:url" content={canonical} />
<meta property="og:locale" content={lang === 'fr' ? 'fr_FR' : 'en_US'} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={resolvedTitle} />
<meta name="twitter:description" content={resolvedDescription} />
<link rel="alternate" hrefLang="fr" href={`${SITE_URL}/fr${path}`} />
<link rel="alternate" hrefLang="en" href={`${SITE_URL}/en${path}`} />
<link rel="alternate" hrefLang="x-default" href={`${SITE_URL}/fr${path}`} />
{jsonLd && <script type="application/ld+json">{JSON.stringify(jsonLd)}</script>}
</Head>
);
}
+1
View File
@@ -0,0 +1 @@
export const conversionPages = [];
+14
View File
@@ -0,0 +1,14 @@
import { conversionPages } from './conversionPages.js';
export const SUPPORTED_LANGS = ['fr', 'en'];
export function listAllRoutePaths() {
const paths = [];
for (const lang of SUPPORTED_LANGS) {
paths.push(`/${lang}`);
for (const page of conversionPages) {
paths.push(`/${lang}/${page.slugs[lang]}`);
}
}
return paths;
}
+1
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
const STORAGE_KEY = 'theme';
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
+3 -11
View File
@@ -1,15 +1,7 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { ViteReactSSG } from 'vite-react-ssg';
import 'flag-icons/css/flag-icons.min.css';
import './i18n.js';
import './index.css';
import App from './App.jsx';
import { routes } from './routes.jsx';
createRoot(document.getElementById('root')).render(
<StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>
);
export const createRoot = ViteReactSSG({ routes });
+3
View File
@@ -0,0 +1,3 @@
export function ConversionPage({ lang, page }) {
return null;
}
+1 -3
View File
@@ -1,5 +1,4 @@
import { useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { fetchFormats, uploadFiles } from '../api.js';
import { FileCard } from '../FileCard.jsx';
@@ -37,8 +36,7 @@ function defaultQualityFor(targetFormat) {
return DEFAULT_QUALITY[targetFormat] ?? null;
}
export function HomePage() {
const { lang } = useParams();
export function HomePage({ lang }) {
const { t } = useTranslation();
const [pendingFiles, setPendingFiles] = useState([]);
const [submittedJobs, setSubmittedJobs] = useState([]);
+2
View File
@@ -1,10 +1,12 @@
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { SeoHead } from '../components/SeoHead.jsx';
export function NotFound() {
const { t } = useTranslation();
return (
<div className="not-found">
<SeoHead lang="fr" path="/404" noindex title={t('notFound.title')} description={t('notFound.title')} />
<h1>{t('notFound.title')}</h1>
<Link to="/fr/">{t('notFound.backHome')}</Link>
</div>
+28
View File
@@ -0,0 +1,28 @@
import { RootRedirect } from './RootRedirect.jsx';
import { LangLayout } from './components/LangLayout.jsx';
import { HomePage } from './pages/HomePage.jsx';
import { NotFound } from './pages/NotFound.jsx';
import { ConversionPage } from './pages/ConversionPage.jsx';
import { conversionPages } from './data/conversionPages.js';
import { SUPPORTED_LANGS } from './data/routePaths.js';
function buildLangRoute(lang) {
return {
path: `/${lang}`,
Component: () => <LangLayout lang={lang} />,
children: [
{ index: true, Component: () => <HomePage lang={lang} /> },
...conversionPages.map((page) => ({
path: page.slugs[lang],
Component: () => <ConversionPage lang={lang} page={page} />,
})),
],
};
}
export const routes = [
{ path: '/', Component: RootRedirect },
...SUPPORTED_LANGS.map(buildLangRoute),
{ path: '/404', Component: NotFound },
{ path: '*', Component: NotFound },
];
+20
View File
@@ -1,5 +1,20 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import fs from 'node:fs';
import path from 'node:path';
import { listAllRoutePaths } from './src/data/routePaths.js';
const SITE_URL = 'https://convert.ombrora.com';
function writeSitemap(outDir) {
const paths = listAllRoutePaths();
const lastmod = new Date().toISOString().slice(0, 10);
const urls = paths
.map((p) => ` <url>\n <loc>${SITE_URL}${p}/</loc>\n <lastmod>${lastmod}</lastmod>\n </url>`)
.join('\n');
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`;
fs.writeFileSync(path.join(outDir, 'sitemap.xml'), sitemap);
}
export default defineConfig({
plugins: [react()],
@@ -8,4 +23,9 @@ export default defineConfig({
'/api': 'http://localhost:3000',
},
},
ssgOptions: {
dirStyle: 'nested',
script: 'async',
onFinished: writeSitemap,
},
});
+827 -91
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -49,7 +49,8 @@
"sharp": "^0.35.3",
"tar": "^7.5.22",
"turndown": "^7.2.4",
"uuid": "^14.0.1"
"uuid": "^14.0.1",
"vite-react-ssg": "^0.9.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.5",