From 4fba72e99c7953ede952fb6a5e42f7020598e312 Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Mon, 4 May 2026 16:29:50 -0400 Subject: [PATCH] feat: prerender public site pages --- deploy/caddy/Caddyfile | 2 +- docs/SEO.md | 132 ++++++++++++++++++ frontend/package.json | 2 +- frontend/scripts/prerender-public.mjs | 34 +++++ frontend/scripts/write-public-seo.mjs | 61 ++++++++ frontend/src/entry-public-ssr.js | 59 ++++++++ .../src/features/landing/publicPageMeta.js | 51 +++++++ .../src/features/landing/views/BlogsPage.vue | 7 + .../src/features/landing/views/GuidesPage.vue | 7 + .../src/features/landing/views/Landing.vue | 7 + .../features/landing/views/PricingPage.vue | 7 + .../features/landing/views/ProductPage.vue | 7 + frontend/src/main.js | 3 + frontend/vite.config.js | 6 +- 14 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 docs/SEO.md create mode 100644 frontend/scripts/prerender-public.mjs create mode 100644 frontend/scripts/write-public-seo.mjs create mode 100644 frontend/src/entry-public-ssr.js create mode 100644 frontend/src/features/landing/publicPageMeta.js diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 5750b18..c3a8f77 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -15,7 +15,7 @@ } handle { - try_files {path} /index.html + try_files {path} {path}/index.html /index.html file_server } } diff --git a/docs/SEO.md b/docs/SEO.md new file mode 100644 index 0000000..f351969 --- /dev/null +++ b/docs/SEO.md @@ -0,0 +1,132 @@ +# SEO And Public Page Prerendering + +Socialize is primarily a client-side app, but the public marketing pages are prerendered during the frontend build so crawlers can index static HTML. + +## Public Indexed Routes + +These routes are treated as public site pages: + +- `/` +- `/product` +- `/pricing` +- `/blogs` +- `/guides` + +Authenticated app routes under `/app/*` and auth utility routes such as `/login`, `/register`, `/forgot-password`, `/reset-password`, and `/verify-email` are excluded from indexing in `robots.txt`. + +## Build Flow + +The frontend build runs: + +```bash +vite build +vite build --ssr src/entry-public-ssr.js --outDir dist-ssr +node scripts/prerender-public.mjs +node scripts/write-public-seo.mjs +``` + +This is wired into: + +```bash +cd frontend +npm run build +``` + +The prerender step writes static HTML files such as: + +```txt +frontend/dist/index.html +frontend/dist/product/index.html +frontend/dist/pricing/index.html +frontend/dist/blogs/index.html +frontend/dist/guides/index.html +``` + +The SEO generator writes: + +```txt +frontend/dist/robots.txt +frontend/dist/sitemap.xml +``` + +## Production Domain + +Set the public site URL when building for production: + +```bash +cd frontend +VITE_PUBLIC_SITE_URL=https://your-domain.com npm run build +``` + +This value is used for: + +- canonical URLs +- sitemap URLs +- the sitemap reference in `robots.txt` + +If `VITE_PUBLIC_SITE_URL` is not set, the build falls back to `SITE_URL`, then `http://localhost:5173`. + +## Files To Update + +When adding, removing, or renaming public indexed pages, update all of these: + +- `frontend/src/router/router.js` +- `frontend/src/entry-public-ssr.js` +- `frontend/scripts/prerender-public.mjs` +- `frontend/scripts/write-public-seo.mjs` +- page metadata in the public page component via `usePublicPageMeta` + +Public page metadata helper: + +```txt +frontend/src/features/landing/publicPageMeta.js +``` + +## Server Routing + +Caddy is configured to serve prerendered directory indexes before falling back to the SPA: + +```txt +try_files {path} {path}/index.html /index.html +``` + +Config file: + +```txt +deploy/caddy/Caddyfile +``` + +This matters because `/product` should serve `dist/product/index.html`, not the SPA fallback `dist/index.html`. + +## Validation + +After changes: + +```bash +cd frontend +VITE_PUBLIC_SITE_URL=https://your-domain.com npm run build +``` + +Check generated HTML: + +```bash +grep -n "" dist/product/index.html +grep -n "canonical" dist/product/index.html +grep -n "Social media content approval" dist/product/index.html +``` + +Check crawler files: + +```bash +cat dist/robots.txt +cat dist/sitemap.xml +``` + +## Search Engine Setup + +After deployment: + +1. Confirm public routes return `200`. +2. Confirm `/robots.txt` and `/sitemap.xml` are served. +3. Submit the sitemap in Google Search Console. +4. Keep auth and app routes out of the sitemap unless they become public content. diff --git a/frontend/package.json b/frontend/package.json index c2bf744..0f391fb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && vite build --ssr src/entry-public-ssr.js --outDir dist-ssr && node scripts/prerender-public.mjs && node scripts/write-public-seo.mjs", "preview": "vite preview", "api:schema": "node scripts/fetch-openapi.mjs", "api:types": "openapi-typescript ../shared/openapi/openapi.json -o src/api/schema.d.ts", diff --git a/frontend/scripts/prerender-public.mjs b/frontend/scripts/prerender-public.mjs new file mode 100644 index 0000000..51d40e3 --- /dev/null +++ b/frontend/scripts/prerender-public.mjs @@ -0,0 +1,34 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const publicRoutes = ['/', '/product', '/pricing', '/blogs', '/guides']; + +const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const distDir = resolve(rootDir, 'dist'); +const ssrEntry = resolve(rootDir, 'dist-ssr/entry-public-ssr.js'); +const templatePath = resolve(distDir, 'index.html'); + +const template = await readFile(templatePath, 'utf8'); +const { render } = await import(pathToFileURL(ssrEntry)); +const baseTemplate = template.replace('<title>Socialize', ''); + +function outputPathForRoute(route) { + if (route === '/') { + return resolve(distDir, 'index.html'); + } + + return resolve(distDir, route.replace(/^\//, ''), 'index.html'); +} + +for (const route of publicRoutes) { + const { appHtml, headTags } = await render(route); + const html = baseTemplate + .replace('', `${headTags.headTags}\n`) + .replace('
', `
${appHtml}
`); + + const outputPath = outputPathForRoute(route); + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, html); + console.log(`Prerendered ${route}`); +} diff --git a/frontend/scripts/write-public-seo.mjs b/frontend/scripts/write-public-seo.mjs new file mode 100644 index 0000000..d4ac8f5 --- /dev/null +++ b/frontend/scripts/write-public-seo.mjs @@ -0,0 +1,61 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +const publicRoutes = [ + { path: '/', changefreq: 'weekly', priority: '1.0' }, + { path: '/product', changefreq: 'weekly', priority: '0.8' }, + { path: '/pricing', changefreq: 'monthly', priority: '0.7' }, + { path: '/blogs', changefreq: 'weekly', priority: '0.6' }, + { path: '/guides', changefreq: 'weekly', priority: '0.6' }, +]; + +const disallowedRoutes = [ + '/app/', + '/login', + '/register', + '/forgot-password', + '/reset-password', + '/verify-email', +]; + +const siteUrl = (process.env.VITE_PUBLIC_SITE_URL ?? process.env.SITE_URL ?? 'http://localhost:5173') + .replace(/\/$/, ''); + +const distDir = resolve(process.cwd(), 'dist'); + +function absoluteUrl(path) { + return new URL(path, `${siteUrl}/`).toString(); +} + +const robots = [ + 'User-agent: *', + 'Allow: /', + ...disallowedRoutes.map(route => `Disallow: ${route}`), + '', + `Sitemap: ${absoluteUrl('/sitemap.xml')}`, + '', +].join('\n'); + +const sitemapEntries = publicRoutes + .map(route => [ + ' ', + ` ${absoluteUrl(route.path)}`, + ` ${route.changefreq}`, + ` ${route.priority}`, + ' ', + ].join('\n')) + .join('\n'); + +const sitemap = [ + '', + '', + sitemapEntries, + '', + '', +].join('\n'); + +await mkdir(distDir, { recursive: true }); +await writeFile(resolve(distDir, 'robots.txt'), robots); +await writeFile(resolve(distDir, 'sitemap.xml'), sitemap); + +console.log(`Wrote public SEO files for ${siteUrl}`); diff --git a/frontend/src/entry-public-ssr.js b/frontend/src/entry-public-ssr.js new file mode 100644 index 0000000..547b450 --- /dev/null +++ b/frontend/src/entry-public-ssr.js @@ -0,0 +1,59 @@ +import { createSSRApp, h } from 'vue'; +import { createMemoryHistory, createRouter, RouterView } from 'vue-router'; +import { createHead, renderHeadToString } from '@vueuse/head'; +import { renderToString } from '@vue/server-renderer'; +import { createI18n } from 'vue-i18n'; +import en from '@/locales/en.json'; +import fr from '@/locales/fr.json'; +import Landing from '@/features/landing/views/Landing.vue'; +import ProductPage from '@/features/landing/views/ProductPage.vue'; +import PricingPage from '@/features/landing/views/PricingPage.vue'; +import BlogsPage from '@/features/landing/views/BlogsPage.vue'; +import GuidesPage from '@/features/landing/views/GuidesPage.vue'; +import './assets/main.css'; + +const publicRoutes = [ + { path: '/', component: Landing }, + { path: '/product', component: ProductPage }, + { path: '/pricing', component: PricingPage }, + { path: '/blogs', component: BlogsPage }, + { path: '/guides', component: GuidesPage }, + { path: '/login', component: { render: () => null } }, + { path: '/register', component: { render: () => null } }, +]; + +export async function render(routePath) { + const router = createRouter({ + history: createMemoryHistory(), + routes: publicRoutes, + }); + + const head = createHead(); + const i18n = createI18n({ + legacy: false, + fallbackLocale: 'en', + messages: { + en, + fr, + }, + }); + + const app = createSSRApp({ + render: () => h(RouterView), + }); + + app.use(router); + app.use(head); + app.use(i18n); + + await router.push(routePath); + await router.isReady(); + + const appHtml = await renderToString(app); + const headTags = await renderHeadToString(head); + + return { + appHtml, + headTags, + }; +} diff --git a/frontend/src/features/landing/publicPageMeta.js b/frontend/src/features/landing/publicPageMeta.js new file mode 100644 index 0000000..9132047 --- /dev/null +++ b/frontend/src/features/landing/publicPageMeta.js @@ -0,0 +1,51 @@ +import { useHead } from '@vueuse/head'; + +function getCanonicalUrl(path) { + const configuredSiteUrl = import.meta.env.VITE_PUBLIC_SITE_URL + ?? (typeof process !== 'undefined' ? process.env.VITE_PUBLIC_SITE_URL : undefined) + ?? (typeof process !== 'undefined' ? process.env.SITE_URL : undefined); + + if (configuredSiteUrl) { + return new URL(path, `${configuredSiteUrl.replace(/\/$/, '')}/`).toString(); + } + + if (typeof window === 'undefined') { + return path; + } + + return new URL(path, window.location.origin).toString(); +} + +export function usePublicPageMeta({ title, description, path }) { + useHead({ + title, + meta: [ + { + name: 'description', + content: description, + }, + { + name: 'robots', + content: 'index,follow', + }, + { + property: 'og:title', + content: title, + }, + { + property: 'og:description', + content: description, + }, + { + property: 'og:type', + content: 'website', + }, + ], + link: [ + { + rel: 'canonical', + href: getCanonicalUrl(path), + }, + ], + }); +} diff --git a/frontend/src/features/landing/views/BlogsPage.vue b/frontend/src/features/landing/views/BlogsPage.vue index 6e29d6e..b9b3ae0 100644 --- a/frontend/src/features/landing/views/BlogsPage.vue +++ b/frontend/src/features/landing/views/BlogsPage.vue @@ -1,5 +1,12 @@