feat: prerender public site pages

This commit is contained in:
2026-05-04 16:29:50 -04:00
parent 55d8acef4c
commit 4fba72e99c
14 changed files with 380 additions and 5 deletions

View File

@@ -15,7 +15,7 @@
}
handle {
try_files {path} /index.html
try_files {path} {path}/index.html /index.html
file_server
}
}

132
docs/SEO.md Normal file
View File

@@ -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 "<title>" 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.

View File

@@ -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",

View File

@@ -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</title>', '');
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('</head>', `${headTags.headTags}\n</head>`)
.replace('<div id="app"></div>', `<div id="app">${appHtml}</div>`);
const outputPath = outputPathForRoute(route);
await mkdir(dirname(outputPath), { recursive: true });
await writeFile(outputPath, html);
console.log(`Prerendered ${route}`);
}

View File

@@ -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 => [
' <url>',
` <loc>${absoluteUrl(route.path)}</loc>`,
` <changefreq>${route.changefreq}</changefreq>`,
` <priority>${route.priority}</priority>`,
' </url>',
].join('\n'))
.join('\n');
const sitemap = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
sitemapEntries,
'</urlset>',
'',
].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}`);

View File

@@ -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,
};
}

View File

@@ -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),
},
],
});
}

View File

@@ -1,5 +1,12 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Blogs | Socialize',
description: 'Practical articles on content review workflows, client approval, revision tracking, and publication handoff.',
path: '/blogs',
});
</script>
<template>

View File

@@ -1,5 +1,12 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Guides | Socialize',
description: 'Reusable guides for content intake, review rounds, approval decisions, and delivery readiness.',
path: '/guides',
});
</script>
<template>

View File

@@ -1,6 +1,13 @@
<script setup>
import { computed } from 'vue';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Socialize | Social media approval workflow',
description: 'Socialize helps teams manage social media content review, revisions, approval decisions, and publication handoff in one workflow.',
path: '/',
});
const pillars = computed(() => [
{

View File

@@ -1,5 +1,12 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Pricing | Socialize',
description: 'Socialize workspace pricing for teams managing social media content approvals with clients.',
path: '/pricing',
});
</script>
<template>

View File

@@ -1,5 +1,12 @@
<script setup>
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js';
usePublicPageMeta({
title: 'Product | Socialize',
description: 'Socialize keeps content items, assets, revisions, comments, approval decisions, and publishing handoff details in one workspace.',
path: '/product',
});
</script>
<template>

View File

@@ -34,6 +34,7 @@ import { useNotificationsStore } from '@/features/notifications/stores/notificat
import { useChannelsStore } from '@/features/channels/stores/channelsStore.js';
import { i18n } from '@/plugins/i18n.js';
import config from '@/config.js';
import { createHead } from '@vueuse/head';
const vuetify = createVuetify({
components: {
@@ -78,9 +79,11 @@ const vuetify = createVuetify({
});
const pinia = createPinia();
const head = createHead();
const app = createApp(App)
.use(pinia)
.use(head)
.use(vuetify)
.use(router)
.use(i18n)

View File

@@ -6,7 +6,7 @@ import { resolve } from 'path'
import { visualizer } from 'rollup-plugin-visualizer'
// https://vitejs.dev/config/
export default defineConfig({
export default defineConfig(({ isSsrBuild }) => ({
plugins: [
visualizer({
filename: './dist/stats.html',
@@ -37,7 +37,7 @@ export default defineConfig({
build: {
sourcemap: true, // Enable source maps for debugging
rollupOptions: {
output: {
output: isSsrBuild ? undefined : {
manualChunks: {
vue: ['vue'],
vuetify: ['vuetify'],
@@ -59,4 +59,4 @@ export default defineConfig({
json: {
stringify: false
}
})
}))