composable-svelte-ssr
Server-side rendering patterns for Composable Svelte. Use when implementing SSR, hydration, server rendering, isomorphic code, or working with meta tags and SEO. Covers renderToHTML, hydrateStore, server-side routing, state serialization, and avoiding common SSR pitfalls.
When & Why to Use This Skill
This Claude skill provides comprehensive patterns and implementation guides for Server-Side Rendering (SSR) and Static Site Generation (SSG) within Composable Svelte applications. It focuses on optimizing web performance and SEO through advanced hydration techniques, state serialization, isomorphic code patterns, and state-driven meta tag management, helping developers build robust, search-engine-friendly web architectures.
Use Cases
- SEO & Meta Tag Management: Implementing dynamic, state-driven meta tags and Open Graph data to ensure web pages are fully indexable and social-media ready.
- Performance Optimization: Reducing Time to Interactive (TTI) by pre-rendering HTML on the server and hydrating the client-side store with pre-populated data.
- Static Site Generation (SSG): Building high-performance, content-heavy websites like blogs or documentation portals using build-time rendering patterns.
- Isomorphic Application Development: Designing shared logic for routing and state management that operates consistently across both Node.js server and browser environments.
- Localized SSR: Implementing internationalization (i18n) workflows that detect user locales on the server and pre-load translations for a seamless localized experience.
- State Leak Prevention: Applying best practices for per-request store creation to prevent sensitive data from leaking between different users in a multi-tenant server environment.
| name | composable-svelte-ssr |
|---|---|
| description | Server-side rendering patterns for Composable Svelte. Use when implementing SSR, hydration, server rendering, isomorphic code, or working with meta tags and SEO. Covers renderToHTML, hydrateStore, server-side routing, state serialization, and avoiding common SSR pitfalls. |
Composable Svelte SSR
This skill covers server-side rendering (SSR) patterns for Composable Svelte applications.
SSR APIs
renderToHTML - Server Rendering
import { renderToHTML } from '@composable-svelte/core/ssr';
import { createStore } from '@composable-svelte/core';
import App from './App.svelte';
app.get('/', async (req, res) => {
// 1. Load data for this request
const data = await loadData(req.user);
// 2. Create store with pre-populated data
const store = createStore({
initialState: data,
reducer: appReducer,
dependencies: {} // Empty on server - effects won't run
});
// 3. Render to HTML
const html = renderToHTML(App, { store }, {
head: `<link rel="stylesheet" href="/assets/index.css">`,
clientScript: '/assets/index.js'
});
// 4. Send response
res.send(html);
});
hydrateStore - Client Hydration
import { hydrateStore } from '@composable-svelte/core/ssr';
import { mount } from 'svelte';
import App from './App.svelte';
// 1. Read state from script tag
const stateJSON = document.getElementById('__COMPOSABLE_SVELTE_STATE__')?.textContent;
// 2. Hydrate with client dependencies
const store = hydrateStore(stateJSON, {
reducer: appReducer,
dependencies: {
api: createAPIClient(),
storage: createLocalStorage()
}
});
// 3. Mount app (reuses existing DOM from SSR)
mount(App, { target: document.body, props: { store } });
ISOMORPHIC PATTERNS
Server vs Client Dependencies
Key Pattern: Server has empty dependencies, client has real implementations.
// server.ts
const store = createStore({
initialState: data,
reducer: appReducer,
dependencies: {} as AppDependencies // Empty - effects won't run
// ssr.deferEffects defaults to true, so effects are automatically skipped
});
// client.ts
const store = hydrateStore(stateJSON, {
reducer: appReducer,
dependencies: {
api: createAPIClient(), // Real API client
storage: localStorage, // Real storage
clock: new SystemClock() // Real clock
}
});
Why: Server doesn't need to execute effects - it just renders initial state. Client needs real dependencies for interactivity.
Router Pure Functions on Server
Key Pattern: Use router's pure functions (parseDestination, matchPath, serializeDestination) on both server and client.
// routing.ts (shared between server and client)
export function parsePostFromURL(path: string, defaultId: number): number {
const match = path.match(/^\/posts\/(\d+)$/);
return match ? parseInt(match[1], 10) : defaultId;
}
// server.ts
async function renderApp(request: any, reply: any) {
const posts = await loadPosts();
const path = request.url;
const requestedPostId = parsePostFromURL(path, posts[0]?.id || 1);
const store = createStore({
initialState: {
posts,
selectedPostId: requestedPostId,
meta: computeMetaForPost(posts.find(p => p.id === requestedPostId))
},
reducer: appReducer,
dependencies: {}
});
const html = renderToHTML(App, { store });
reply.type('text/html').send(html);
}
STATE INITIALIZATION FROM URL
Pattern: Parse URL on Server, Initialize State
// server.ts
import { parseDestination } from './shared/routing';
app.get('/posts/:id', async (req, res) => {
const postId = parseInt(req.params.id, 10);
// Load data based on URL
const posts = await loadPosts();
const selectedPost = posts.find(p => p.id === postId) || posts[0];
// Initialize state with URL-driven selection
const store = createStore({
initialState: {
posts,
selectedPostId: selectedPost?.id || null,
// Set initial meta based on URL-selected post
meta: selectedPost
? {
title: `${selectedPost.title} - Blog`,
description: selectedPost.content.slice(0, 160),
ogImage: `/og/post-${selectedPost.id}.jpg`,
canonical: `https://example.com/posts/${selectedPost.id}`
}
: initialState.meta
},
reducer: appReducer,
dependencies: {}
});
const html = renderToHTML(App, { store });
res.send(html);
});
STATE SERIALIZATION/DESERIALIZATION
Automatic Serialization
When you call renderToHTML, the store state is automatically serialized and embedded in the HTML:
const html = renderToHTML(App, { store });
// HTML contains: <script id="__COMPOSABLE_SVELTE_STATE__" type="application/json">...</script>
Automatic Deserialization
When you call hydrateStore, the state is automatically deserialized:
const stateJSON = document.getElementById('__COMPOSABLE_SVELTE_STATE__')?.textContent;
const store = hydrateStore(stateJSON, { reducer, dependencies });
Custom Serialization (Advanced)
For complex types (Date, Map, Set), provide custom serializers:
import { serializeState, parseState } from '@composable-svelte/core/ssr';
// Server
const serialized = serializeState(store.state, {
customSerializers: {
Date: (date) => ({ __type: 'Date', value: date.toISOString() }),
Map: (map) => ({ __type: 'Map', entries: Array.from(map.entries()) })
}
});
// Client
const state = parseState(serialized, {
customParsers: {
Date: (obj) => new Date(obj.value),
Map: (obj) => new Map(obj.entries)
}
});
STATE-DRIVEN META TAGS
Pattern: Compute Meta Tags in Reducer
Best Practice: Meta tags should be computed from state in the reducer, then rendered via <svelte:head> in components.
// State
interface AppState {
posts: Post[];
selectedPostId: number | null;
meta: MetaTags;
}
interface MetaTags {
title: string;
description: string;
ogImage?: string;
canonical?: string;
}
// Reducer computes meta tags
case 'selectPost': {
const post = state.posts.find(p => p.id === action.postId);
return [
{
...state,
selectedPostId: action.postId,
meta: post
? {
title: `${post.title} - Blog`,
description: post.content.slice(0, 160),
ogImage: `/og/post-${post.id}.jpg`,
canonical: `https://example.com/posts/${post.id}`
}
: state.meta
},
Effect.none()
];
}
// Component renders meta tags
<svelte:head>
<title>{$store.meta.title}</title>
<meta name="description" content={$store.meta.description} />
{#if $store.meta.ogImage}
<meta property="og:title" content={$store.meta.title} />
<meta property="og:description" content={$store.meta.description} />
<meta property="og:image" content={$store.meta.ogImage} />
{/if}
{#if $store.meta.canonical}
<link rel="canonical" href={$store.meta.canonical} />
{/if}
</svelte:head>
Why: Meta tags are part of application state. Computing them in the reducer ensures they're consistent on server and client, and testable with TestStore.
COMPLETE SSR EXAMPLE
Server (Fastify)
// server/index.ts
import Fastify from 'fastify';
import fastifyStatic from '@fastify/static';
import { createStore } from '@composable-svelte/core';
import { renderToHTML } from '@composable-svelte/core/ssr';
import App from '../shared/App.svelte';
import { appReducer } from '../shared/reducer';
import { initialState } from '../shared/types';
import { loadPosts } from './data';
import { parsePostFromURL } from '../shared/routing';
const app = Fastify({
logger: {
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug'
}
});
// Serve static files (client bundle)
app.register(fastifyStatic, {
root: join(__dirname, '../client'),
prefix: '/assets/'
});
// Main SSR route handler
async function renderAppRoute(request: any, reply: any) {
try {
// 1. Parse URL using router (same logic as client!)
const posts = await loadPosts();
const path = request.url;
const requestedPostId = parsePostFromURL(path, posts[0]?.id || 1);
// Find the requested post
const selectedPost = posts.find((p) => p.id === requestedPostId) || posts[0];
// 2. Create store with URL-driven state
const store = createStore({
initialState: {
...initialState,
posts,
selectedPostId: selectedPost?.id || null,
// Set initial meta based on URL-selected post
meta: selectedPost
? {
title: `${selectedPost.title} - Blog`,
description: selectedPost.content.slice(0, 160),
ogImage: `/og/post-${selectedPost.id}.jpg`,
canonical: `https://example.com/posts/${selectedPost.id}`
}
: initialState.meta
},
reducer: appReducer,
dependencies: {}
// ssr.deferEffects defaults to true, so effects are automatically skipped
});
// 3. Render component to HTML
const html = renderToHTML(App, { store }, {
head: `
<link rel="stylesheet" href="/assets/index.css">
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; }
</style>
`,
clientScript: '/assets/index.js'
});
// 4. Send response
reply.type('text/html').send(html);
} catch (error) {
request.log.error(error);
reply.status(500).send({
error: 'Internal Server Error',
message: error instanceof Error ? error.message : 'Unknown error'
});
}
}
// Register routes
app.get('/', renderAppRoute);
app.get('/posts/:id', renderAppRoute);
// Start server
const start = async () => {
try {
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
const host = process.env.HOST || '0.0.0.0';
await app.listen({ port, host });
console.log(`Server running at http://localhost:${port}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
Client
// client/index.ts
import { hydrate as hydrateComponent } from 'svelte';
import { hydrateStore } from '@composable-svelte/core/ssr';
import { syncBrowserHistory } from '@composable-svelte/core/routing';
import App from '../shared/App.svelte';
import { appReducer } from '../shared/reducer';
import type { AppDependencies, AppState, AppAction } from '../shared/types';
import { parserConfig, serializerConfig } from '../shared/routing';
// Client-side dependencies
const clientDependencies: AppDependencies = {
fetchPosts: async () => {
// In a real app, this would fetch from an API
// For this example, we'll just return empty array
// (the data is already loaded via SSR)
return [];
}
};
// Hydrate the application
function hydrate() {
try {
// 1. Read serialized state from the server
const stateElement = document.getElementById('__COMPOSABLE_SVELTE_STATE__');
if (!stateElement || !stateElement.textContent) {
throw new Error('No hydration data found. Server-side rendering may have failed.');
}
// 2. Hydrate the store with client dependencies
const store = hydrateStore<AppState, AppAction, AppDependencies>(
stateElement.textContent,
{
reducer: appReducer,
dependencies: clientDependencies
}
);
// 3. Sync browser history with state (URL routing!)
syncBrowserHistory(store, {
serializers: serializerConfig.serializers,
parsers: parserConfig.parsers,
// Map state → destination for URL serialization
getDestination: (state) => {
if (state.selectedPostId !== null) {
return { type: 'post' as const, state: { postId: state.selectedPostId } };
}
return null;
},
// Map destination → action for back/forward navigation
destinationToAction: (dest) => {
if (dest?.type === 'post') {
return { type: 'selectPost', postId: dest.state.postId };
}
return null;
}
});
// 4. Hydrate the app (reuse existing DOM from SSR)
const app = hydrateComponent(App, {
target: document.body,
props: { store }
});
console.log('✅ Composable Svelte hydrated successfully with URL routing');
// Cleanup on unmount (for HMR during development)
if (import.meta.hot) {
import.meta.hot.dispose(() => {
app.$destroy?.();
});
}
} catch (error) {
console.error('❌ Hydration failed:', error);
// Show error to user
document.body.innerHTML = `
<div style="display: flex; align-items: center; justify-content: center; min-height: 100vh; background: #fee; color: #c00; font-family: monospace; padding: 2rem;">
<div>
<h1>Hydration Error</h1>
<p>${error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
</div>
`;
}
}
// Start hydration when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrate);
} else {
hydrate();
}
AVOIDING STATE LEAKS
❌ WRONG - Shared Store
// server.ts
// ❌ BAD: Shared store across requests
const globalStore = createStore({
initialState: {},
reducer: appReducer,
dependencies: {}
});
app.get('/', (req, res) => {
// ❌ State leaks between requests!
const html = renderToHTML(App, { store: globalStore });
res.send(html);
});
✅ CORRECT - Per-Request Store
// server.ts
// ✅ GOOD: Create new store for each request
app.get('/', async (req, res) => {
const store = createStore({
initialState: await loadDataForRequest(req),
reducer: appReducer,
dependencies: {}
});
const html = renderToHTML(App, { store });
res.send(html);
});
Why: Each request needs its own store to prevent state leaking between users.
I18N SSR PATTERNS
Manual i18n Initialization (Fastify/Custom Servers)
Key Pattern: For non-SvelteKit servers (Fastify, Express, etc.), manually initialize i18n state and dependencies.
import { createInitialI18nState, BundledTranslationLoader, createStaticLocaleDetector, serverDOM, browserDOM } from '@composable-svelte/core/i18n';
// Server: Detect locale from request
function detectLocale(request: any): string {
// 1. Check query param (?lang=fr)
const queryLang = request.query?.lang;
if (queryLang && ['en', 'fr', 'es'].includes(queryLang)) {
return queryLang;
}
// 2. Check Accept-Language header
const acceptLanguage = request.headers?.['accept-language'];
if (acceptLanguage && typeof acceptLanguage === 'string') {
const languages = acceptLanguage.split(',')
.map(lang => lang.trim().split(';')[0].split('-')[0]);
for (const lang of languages) {
if (['en', 'fr', 'es'].includes(lang)) {
return lang;
}
}
}
// 3. Default to English
return 'en';
}
// Server: Initialize i18n for SSR
async function renderApp(request, reply) {
const locale = detectLocale(request);
const i18nState = createInitialI18nState(locale, ['en', 'fr', 'es'], 'en');
// Create translation loader
const translationLoader = new BundledTranslationLoader({
bundles: {
en: { common: enTranslations },
fr: { common: frTranslations },
es: { common: esTranslations }
}
});
// Preload translations for current locale
const translations = await translationLoader.load('common', locale);
const updatedI18nState = {
...i18nState,
translations: { [`${locale}:common`]: translations }
};
// Create mock storage for server (no-op)
const mockStorage = {
getItem: (key: string) => null,
setItem: (key: string, value: unknown) => {},
removeItem: (key: string) => {},
keys: () => [],
has: (key: string) => false,
clear: () => {}
};
// Create i18n dependencies for server
const i18nDependencies = {
translationLoader,
localeDetector: createStaticLocaleDetector(locale, ['en', 'fr', 'es']),
storage: mockStorage,
dom: serverDOM
};
const store = createStore({
initialState: {
...initialState,
i18n: updatedI18nState
},
reducer: appReducer,
dependencies: {
...otherDependencies,
...i18nDependencies
}
});
const html = renderToHTML(App, { store });
reply.type('text/html').send(html);
}
Client i18n Hydration
import { BundledTranslationLoader, createStaticLocaleDetector, browserDOM } from '@composable-svelte/core/i18n';
// Client: Hydrate with localStorage-backed storage
const clientStorage = {
getItem: (key: string) => {
try {
return localStorage.getItem(key);
} catch {
return null;
}
},
setItem: (key: string, value: unknown) => {
try {
localStorage.setItem(key, String(value));
} catch {}
},
removeItem: (key: string) => {
try {
localStorage.removeItem(key);
} catch {}
},
keys: () => {
try {
return Object.keys(localStorage);
} catch {
return [];
}
},
has: (key: string) => {
try {
return localStorage.getItem(key) !== null;
} catch {
return false;
}
},
clear: () => {
try {
localStorage.clear();
} catch {}
}
};
// Hydrate i18n on client
async function hydrate() {
const stateElement = document.getElementById('__COMPOSABLE_SVELTE_STATE__');
const parsedState = JSON.parse(stateElement.textContent);
const locale = parsedState.i18n.currentLocale;
const translationLoader = new BundledTranslationLoader({
bundles: {
en: { common: enTranslations },
fr: { common: frTranslations },
es: { common: esTranslations }
}
});
const i18nDependencies = {
translationLoader,
localeDetector: createStaticLocaleDetector(locale, ['en', 'fr', 'es']),
storage: clientStorage, // Real localStorage
dom: browserDOM
};
const store = hydrateStore(stateElement.textContent, {
reducer: appReducer,
dependencies: {
...otherDependencies,
...i18nDependencies
}
});
hydrateComponent(App, { target: document.body, props: { store } });
}
Storage Interface Requirements
Critical: The i18n system expects a Storage interface, not a Map.
// ❌ WRONG - Map doesn't have setItem/getItem
const i18nDependencies = {
storage: new Map() // TypeError: storage.setItem is not a function
};
// ✅ CORRECT - Implement Storage interface
const mockStorage: Storage = {
getItem: (key: string) => null,
setItem: (key: string, value: unknown) => {},
removeItem: (key: string) => {},
keys: () => [],
has: (key: string) => false,
clear: () => {}
};
i18n + URL Routing Pattern
Combine i18n with URL routing to support language selection via URL:
// Server: Support ?lang=fr query parameter
app.get('/', async (req, res) => {
const locale = detectLocale(req); // Checks ?lang= first, then Accept-Language
const destination = parseDestinationFromURL(req.url);
const store = createStore({
initialState: {
destination,
i18n: createInitialI18nState(locale, ['en', 'fr', 'es'])
// ...
}
});
// ...
});
URL Pattern: http://localhost:3000/?lang=fr or http://localhost:3000/posts/1?lang=es
SSR PERFORMANCE CONSIDERATIONS
1. Defer Effects on Server
Automatic: Effects are automatically deferred on server (via ssr.deferEffects: true default).
// No need to set this explicitly - it's the default
const store = createStore({
initialState: data,
reducer: appReducer,
dependencies: {},
// ssr: { deferEffects: true } // Default
});
2. Load Data Once on Server
app.get('/posts/:id', async (req, res) => {
// Load data once
const posts = await loadPosts();
const postId = parseInt(req.params.id, 10);
const selectedPost = posts.find(p => p.id === postId);
// Initialize state with loaded data
const store = createStore({
initialState: {
posts, // Data already loaded
selectedPostId: postId,
meta: computeMeta(selectedPost)
},
reducer: appReducer,
dependencies: {}
});
const html = renderToHTML(App, { store });
res.send(html);
});
Why: Server pre-loads data, client hydrates with it. No need to fetch again on client.
COMMON SSR PITFALLS
1. Using Browser APIs on Server
❌ WRONG:
// reducer.ts
case 'init':
const theme = localStorage.getItem('theme'); // ❌ localStorage not available on server!
return [{ ...state, theme }, Effect.none()];
✅ CORRECT:
// Use environment detection
import { isServer } from '@composable-svelte/core/ssr';
case 'init':
const theme = isServer ? 'light' : localStorage.getItem('theme') || 'light';
return [{ ...state, theme }, Effect.none()];
2. Not Handling Hydration Errors
❌ WRONG:
// client.ts
const store = hydrateStore(stateJSON, { reducer, dependencies });
mount(App, { target: document.body, props: { store } });
// If hydration fails, user sees blank screen!
✅ CORRECT:
try {
const store = hydrateStore(stateJSON, { reducer, dependencies });
mount(App, { target: document.body, props: { store } });
console.log('✅ Hydrated successfully');
} catch (error) {
console.error('❌ Hydration failed:', error);
// Show error UI
document.body.innerHTML = `<div class="error">Hydration failed: ${error.message}</div>`;
}
3. Forgetting to Set Meta Tags
❌ WRONG:
// No meta tags in state, no <svelte:head> in component
// Search engines see generic meta tags
✅ CORRECT:
// State includes meta tags
interface AppState {
meta: { title: string; description: string; ogImage?: string };
}
// Component renders meta tags
<svelte:head>
<title>{$store.meta.title}</title>
<meta name="description" content={$store.meta.description} />
</svelte:head>
SSR CHECKLIST
- 1. Create new store for each request (no shared state)
- 2. Use empty dependencies on server
- 3. Load data based on URL on server
- 4. Initialize state with loaded data
- 5. Compute meta tags in reducer
- 6. Render meta tags with
<svelte:head> - 7. Hydrate with real dependencies on client
- 8. Sync browser history on client (if using routing)
- 9. Handle hydration errors gracefully
- 10. Use environment detection for browser APIs
STATIC SITE GENERATION (SSG)
generateStaticSite - Build-Time Generation
import { generateStaticSite } from '@composable-svelte/core/ssr';
import App from './App.svelte';
import { appReducer } from './reducer';
const posts = await loadPosts();
const result = await generateStaticSite(App, {
routes: [
{ path: '/' },
{ path: '/about' },
{
path: '/posts/:id',
paths: posts.map(p => `/posts/${p.id}`),
getServerProps: async (path) => ({
post: await loadPost(path)
})
}
],
outDir: './dist',
baseURL: 'https://example.com',
onPageGenerated: (path, outPath) => {
console.log(`Generated ${path} → ${outPath}`);
}
}, {
reducer: appReducer,
dependencies: {},
getInitialState: (path) => ({ /* compute state for path */ })
});
console.log(`Generated ${result.pagesGenerated} pages in ${result.duration}ms`);
SSG Configuration
Full-Site Generation:
// Generate all routes at build time
await generateStaticSite(App, {
routes: [
{ path: '/' }, // Static route
{ path: '/about' }, // Static route
{
path: '/posts/:id', // Dynamic route
paths: ['/posts/1', '/posts/2'] // Pre-rendered paths
}
],
outDir: './static'
}, { reducer, dependencies: {} });
Selective Generation:
// Generate only specific pages
await generateStaticSite(App, {
routes: [
{ path: '/' }, // Only homepage
{ path: '/posts/1' } // Only one post
],
outDir: './static'
}, { reducer, dependencies: {} });
Dynamic Path Generation:
// Fetch paths dynamically at build time
await generateStaticSite(App, {
routes: [
{
path: '/posts/:id',
paths: async () => {
const posts = await loadAllPosts();
return posts.map(p => `/posts/${p.id}`);
}
}
],
outDir: './static'
}, { reducer, dependencies: {} });
SSG + i18n Pattern
Multi-Locale Static Generation:
const supportedLocales = ['en', 'fr', 'es'];
const posts = await loadPosts();
// Generate routes for each locale
const routes = [];
for (const locale of supportedLocales) {
const localePrefix = locale === 'en' ? '' : `/${locale}`;
// Home page
routes.push({
path: `${localePrefix}/`,
getServerProps: async (path) => {
const i18nState = await initI18n(locale);
return { ...initialState, i18n: i18nState };
}
});
// Post pages
for (const post of posts) {
routes.push({
path: `${localePrefix}/posts/${post.id}`,
getServerProps: async (path) => {
const i18nState = await initI18n(locale);
const post = await loadPost(post.id);
return { ...initialState, post, i18n: i18nState };
}
});
}
}
await generateStaticSite(App, { routes, outDir: './static' }, { reducer });
SSG Build Script
Create build script (src/build/ssg.ts):
import { generateStaticSite } from '@composable-svelte/core/ssr';
import App from '../shared/App.svelte';
import { appReducer } from '../shared/reducer';
import { loadPosts } from '../server/data';
async function build() {
console.log('Starting SSG build...');
const posts = await loadPosts();
const result = await generateStaticSite(App, {
routes: [
{ path: '/' },
{
path: '/posts/:id',
paths: posts.map(p => `/posts/${p.id}`),
getServerProps: async (path) => {
const id = parseInt(path.split('/').pop()!);
const post = await loadPost(id);
return { posts: [post] };
}
}
],
outDir: './static',
baseURL: 'https://example.com'
}, {
reducer: appReducer,
dependencies: {}
});
console.log(`✅ Generated ${result.pagesGenerated} pages in ${result.duration}ms`);
}
build().catch(console.error);
Add script to package.json:
{
"scripts": {
"build:ssg": "vite build && tsx src/build/ssg.ts"
}
}
Run build:
pnpm build:ssg
SSG vs SSR Decision Matrix
| Use Case | Recommendation | Reason |
|---|---|---|
| Blog posts | SSG | Content rarely changes, many reads |
| User dashboards | SSR | Personalized, private data |
| Product catalog | SSG | Public, static content |
| Search results | SSR | Dynamic, user-specific |
| Marketing pages | SSG | Static, performance-critical |
| Admin panels | SSR | Dynamic, authenticated |
Hybrid SSG + SSR Pattern
Use SSG for static pages, SSR for dynamic:
Build-time (SSG): Generate static pages
pnpm build:ssg # Generates /static/index.html, /static/posts/*/index.htmlRuntime (SSR): Serve dynamic pages
// Server fallback for non-static routes app.get('*', async (req, res) => { // Try to serve static file first const staticPath = join(__dirname, '../static', req.url, 'index.html'); if (existsSync(staticPath)) { return res.sendFile(staticPath); } // Fall back to SSR for dynamic routes const store = createStore({ /* ... */ }); const html = renderToHTML(App, { store }); res.send(html); });
SUMMARY
This skill covers SSR and SSG patterns for Composable Svelte:
- SSR APIs: renderToHTML, hydrateStore
- SSG APIs: generateStaticSite, generateStaticPage
- Isomorphic Patterns: Server vs client dependencies, router pure functions
- State Initialization: Parse URL on server, initialize state
- State Serialization: Automatic serialization/deserialization
- Meta Tags: State-driven meta tags computed by reducer
- i18n SSR/SSG: Manual i18n initialization, locale detection, multi-locale generation
- Complete Examples: Fastify server + SSG build script + client hydration
- Avoiding Pitfalls: Per-request stores, environment detection, error handling
SSR Key Points:
- Create new store for each request
- Use empty dependencies on server
- Hydrate with real dependencies on client
- State is serialized automatically
SSG Key Points:
- Generate static HTML at build time
- Support dynamic routes with path enumeration
- Use getServerProps to load data for each path
- Combine with i18n for multi-locale sites
- Ideal for content-heavy, rarely-changing sites
i18n Key Points:
- Use
BundledTranslationLoaderwithbundleswrapper - Server: Mock storage (no-op),
createStaticLocaleDetector,serverDOM - Client: localStorage-backed storage,
createStaticLocaleDetector,browserDOM - Detect locale from query param → Accept-Language → default
- Storage interface requires
getItem/setItem, not Map'sget/set
For core architecture, see composable-svelte-core skill. For URL routing, see composable-svelte-navigation skill. For testing SSR/SSG, see composable-svelte-testing skill.