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('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 @@
diff --git a/frontend/src/features/landing/views/GuidesPage.vue b/frontend/src/features/landing/views/GuidesPage.vue
index e918b8f..b5ec73b 100644
--- a/frontend/src/features/landing/views/GuidesPage.vue
+++ b/frontend/src/features/landing/views/GuidesPage.vue
@@ -1,5 +1,12 @@
diff --git a/frontend/src/features/landing/views/Landing.vue b/frontend/src/features/landing/views/Landing.vue
index 877c64b..98dd5f7 100644
--- a/frontend/src/features/landing/views/Landing.vue
+++ b/frontend/src/features/landing/views/Landing.vue
@@ -1,6 +1,13 @@
diff --git a/frontend/src/features/landing/views/ProductPage.vue b/frontend/src/features/landing/views/ProductPage.vue
index 1e8803a..9ad5c32 100644
--- a/frontend/src/features/landing/views/ProductPage.vue
+++ b/frontend/src/features/landing/views/ProductPage.vue
@@ -1,5 +1,12 @@
diff --git a/frontend/src/main.js b/frontend/src/main.js
index 02038b3..aa7f3ad 100644
--- a/frontend/src/main.js
+++ b/frontend/src/main.js
@@ -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)
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index 86543ae..84f5b1d 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -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
}
-})
+}))