| 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
-
- Create new store for each request (no shared state)
-
- Use empty dependencies on server
-
- Load data based on URL on server
-
- Initialize state with loaded data
-
- Compute meta tags in reducer
-
- Render meta tags with
<svelte:head>
- Render meta tags with
-
- Hydrate with real dependencies on client
-
- Sync browser history on client (if using routing)
-
- Handle hydration errors gracefully
-
- 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.