SvelteKit SEO: layouts, +page.server, prerendering, hooks
A comprehensive installation and audit reference for SvelteKit as a production framework. SvelteKit ships smaller JavaScript bundles than any other major framework in 2026 per the State of JS 2025…
Canonical 2026 Reference for SvelteKit 2 plus Svelte 5: Runes, Adapters, SSR by Default, Prerendering, Form Actions, Hooks, Schema in svelte:head, International Routing, and Self Hosted Ops on Debian/nginx
A comprehensive installation and audit reference for SvelteKit as a production framework. SvelteKit ships smaller JavaScript bundles than any other major framework in 2026 per the State of JS 2025 framework benchmark (sample 23,765 developers, median first load JS 41 KB for SvelteKit versus 87 KB Next.js, 96 KB Nuxt, 71 KB Remix, 14 KB Astro). The combination of compile time reactivity, the runes system introduced in Svelte 5, SSR by default, and the adapter pattern produces sites that pass Core Web Vitals with minimal optimization work.
Cross stack implementation note: code samples below are TypeScript, Svelte components, and bash. For React, Vue, Astro, Hugo, Eleventy, Next.js, Nuxt, Remix, Hydrogen, WordPress, and Webflow equivalents of every pattern in this document see framework-cross-stack-implementation.md. For schema variants per page type see framework-schema.md. For Joseph's Bubbles deployment pattern in detail see Section 14 of this document.
1. Document Purpose
SvelteKit is the official application framework for Svelte. It pairs Svelte's compile time reactivity model with file based routing, server side rendering by default, an adapter system that targets every major deployment surface, and a load function pattern that unifies server and client data fetching. The result is a framework that ships less JavaScript than the React or Vue ecosystem competitors while preserving developer experience.
For SEO in 2026, SvelteKit is rarely the bottleneck. The default rendering path is SSR with hydration; the default first load JavaScript is small; the prerender configuration converts any page to static at build time with a single line. Common failure modes come not from the framework but from teams treating SvelteKit like a single page application and disabling SSR globally, missing canonical handling on dynamic routes, or shipping client only data fetching for indexable content.
This framework specifies SvelteKit SEO implementation including the rendering matrix, svelte:head patterns, adapter selection, the form actions pattern with progressive enhancement, internationalization, and the self hosted Node.js deployment pattern Joseph uses on Bubbles.
1.1 SvelteKit vs the Field
| Framework | First Load JS (median) | SSR Default | Adapter Pattern |
|---|---|---|---|
| SvelteKit 2 | 41 KB | Yes | Yes |
| Next.js 15 | 87 KB | App Router yes | Built in |
| Nuxt 3 | 96 KB | Yes | Nitro presets |
| Remix 2 | 71 KB | Yes | Yes |
| Astro 5 | 14 KB | Islands | Yes |
SvelteKit wins on shipped JavaScript among full SSR frameworks. Astro beats it for static content sites. Next.js wins on ecosystem breadth. Nuxt wins for Vue teams. Remix wins on form ergonomics, though SvelteKit's form actions reach parity in 2026.
SvelteKit fits teams that value small bundles, a single component file format covering markup plus style plus logic, and the willingness to learn the runes mental model. It does not fit teams needing React ecosystem dependencies or Next.js platform features (ISR at scale, Vercel edge middleware).
1.2 When to Recommend SvelteKit
Fits: marketing sites, blogs, documentation, dashboards behind auth, headless e commerce, internal tools, mid scale content sites under 5,000 pages, Joseph's portfolio of standalone domain client sites where Bubbles self hosted Node.js plus nginx is the deployment target.
Does not fit: teams committed to React, projects depending on a React only library, sites needing Vercel ISR or partial prerendering specifically.
1.3 Operating Modes
Mode A, Install. New project. Follow Sections 2 through 14. Mode B, Audit. Existing project. Skip to audit checklists. Mode C, Migrate from Svelte 4 plus SvelteKit 1. Skip to Section 13. Mode D, Migrate from React or Next.js. Skip to Section 13.
1.4 Required Tools
Node.js 20 or 22 LTS, pnpm 9 or npm 10, Git, Vite 5, TypeScript 5.4 plus, staging environment, Google Search Console, PageSpeed Insights, Lighthouse, PM2 for self hosted deployment.
2. Client Variables Intake
Stored at /var/www/sites/[domain]/audit/sveltekit/intake.yaml. The intake gates audit and optimization work; generic advice without it produces inaccurate recommendations.
# SVELTEKIT SEO CLIENT VARIABLES
business_name: ""
primary_domain: ""
launch_date: ""
project_phase: "" # design, build, launch, audit
# SvelteKit and Svelte versions
svelte_version: "" # 5.x.y
sveltekit_version: "" # 2.x.y
vite_version: "" # 5.x.y
typescript_used: true
node_version: "" # 20.x or 22.x
package_manager: "" # pnpm, npm, yarn, bun
# Runes adoption (Svelte 5 specific)
runes_mode_enabled: false # if false, project uses Svelte 4 reactivity
mixed_runes_legacy: false # if true, project has some files in each mode
# Adapter and deployment
adapter: "" # node, static, vercel, netlify, auto
deployment_target: "" # self_hosted_bubbles, vercel, netlify, fly_io, render
custom_domain_configured: false
ssl_method: "" # letsencrypt, vercel_managed
preview_environment_present: true
# Routing
url_prefix_scheme: "" # flat, grouped, locale_prefixed
trailing_slash_policy: "" # always, never, ignore
locale_routing: "" # none, subpath, subdomain, accept_language
locale_list: []
canonical_strategy: "" # per_page, layout_default, hook
# Rendering posture per route type
homepage_render: "" # ssr, prerender, csr
marketing_pages_render: "" # ssr, prerender, csr
blog_index_render: "" # ssr, prerender, csr
blog_post_render: "" # ssr, prerender, csr
product_listing_render: "" # ssr, prerender, csr
product_detail_render: "" # ssr, prerender, csr
search_results_render: "" # ssr, prerender, csr
dashboard_render: "" # ssr, csr_auth
# Content scale
total_pages: 0
total_blog_posts: 0
total_dynamic_routes: 0
prerender_at_build_routes: 0
# Performance baseline (mobile, p75)
lcp_mobile_p75_ms: 0
inp_mobile_p75_ms: 0
cls_mobile_p75: 0
ttfb_mobile_p75_ms: 0
first_load_js_kb: 0
lighthouse_mobile_perf: 0
lighthouse_mobile_seo: 0
# SEO state
sitemap_present: false
sitemap_method: "" # endpoint, static_file, plugin
robots_txt_present: false
canonical_present_all_routes: false
schema_json_ld_present: false
schema_graph_pattern_used: false
hreflang_present: false
og_image_strategy: "" # static, dynamic_generated, none
# Form handling
forms_use_enhance: false
forms_action_pattern: "" # form_actions, api_route, third_party
# Hosting specifics (if Bubbles self hosted)
nginx_vhost_path: "" # /etc/nginx/sites-available/[domain]
pm2_app_name: ""
pm2_instance_count: 0 # cluster mode worker count
node_listen_port: 0
deploy_pipeline: "" # git_pull_pnpm_build_pm2_reload, ci_cd
The intake is gating. Audit and optimization work cannot run cleanly without it.
3. SvelteKit Platform Overview 2026
SvelteKit and Svelte ship as separate packages with coupled release cadence. Svelte 5 stabilized late 2024 introducing the runes reactivity system. SvelteKit 2 followed early 2025. As of 2026 both are stable and recommended for new projects.
3.1 Svelte 5 and the Runes System
Svelte 5 replaces the implicit reactivity of Svelte 4 (assignment triggers reactivity, $: for reactive statements) with explicit runes. Runes are compiler primitives marked with the $ prefix.
<script lang="ts">
let count = $state(0);
let doubled = $derived(count * 2);
$effect(() => { console.log(`count is now ${count}`); });
function increment() { count += 1; }
</script>
<button onclick={increment}>{count} (doubled: {doubled})</button>
The runes ($state, $derived, $effect, $props, $bindable, $inspect, $host) make reactivity tracking explicit. Runes mode adds no runtime overhead. For SEO purposes the runes system is transparent: SSR output is identical, hydration cost unchanged. New projects use runes mode exclusively.
3.2 SvelteKit 2
SvelteKit 2 stabilized the public API around load functions, form actions, and adapters. Breaking changes from SvelteKit 1 were minor: cookie handling moved to typed API, goto signature tightened, deprecated patterns removed. See framework-migration.md.
3.3 The Adapter Pattern
SvelteKit separates application code from deployment target via adapters. Application authors write framework agnostic code; the build step uses the configured adapter to produce target specific output.
// svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: {
adapter: adapter({ out: 'build', precompress: true, envPrefix: 'PUBLIC_' })
}
};
Adapter selection determines whether output is a Node.js server, static file tree, or platform specific package. See Section 9.
3.4 Vite as the Build Tool
SvelteKit uses Vite for development and production builds. Vite provides millisecond HMR, TypeScript type checking via vite-plugin-checker, and a plugin ecosystem covering image optimization, MDX, GraphQL codegen. Production builds include code splitting, asset hashing for long term caching, and dead code elimination.
3.5 File Based Routing
Routes live under src/routes/. Each directory represents a URL segment. A directory containing +page.svelte is a page; one containing +server.ts is an API endpoint; +layout.svelte is a nested layout. Dynamic segments use bracket syntax ([param]), rest segments double brackets ([...rest]).
src/routes/
+layout.svelte # root layout
+page.svelte # homepage at /
about/+page.svelte # /about
blog/+page.svelte # /blog index
blog/[slug]/+page.svelte # /blog/:slug
blog/[slug]/+page.server.ts # server load
products/[category]/[slug]/+page.svelte
api/contact/+server.ts # POST /api/contact
3.6 The Load Function Pattern
Each route can export a load function. Load functions run server side for +page.server.ts and +layout.server.ts; universally (server first, client on navigation) for +page.ts and +layout.ts.
// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { getPostBySlug } from '$lib/server/posts';
export const load: PageServerLoad = async ({ params, setHeaders }) => {
const post = await getPostBySlug(params.slug);
if (!post) error(404, 'Post not found');
setHeaders({ 'cache-control': 'public, max-age=300, s-maxage=86400, stale-while-revalidate=604800' });
return { post };
};
Load function data flows into +page.svelte via the data prop. SSR returns complete HTML with data embedded.
4. Rendering Modes
SvelteKit's rendering matrix is configured per route via three exports: prerender, ssr, and csr. Defaults produce SSR with full client hydration; opting into static via prerender or pure client side via ssr = false are deliberate per route choices.
4.1 SSR by Default
Every route renders on the server by default. The load function executes on the server, the component renders to HTML, the response includes complete content and meta tags, and the client hydrates after first paint. Crawlers receive complete HTML on first byte.
4.2 Prerender for Static Pages
Routes whose content does not vary per request prerender at build time.
// src/routes/about/+page.ts
export const prerender = true;
For adapter-static, prerendering all routes produces a fully static site. For adapter-node, prerendered routes serve from disk while non prerendered routes invoke the Node.js handler.
Values: true prerenders at build, false renders at request time, 'auto' prerenders if no dynamic params. For marketing pages, documentation, blog post details with stable slugs, and any route with no per request variation, prerender = true is the right choice.
4.3 CSR Opt In
A route can opt out of SSR via ssr = false. The route renders only on the client; the server returns an empty shell. This suits authenticated dashboards where SEO is not desired.
// src/routes/dashboard/+page.ts
export const ssr = false;
export const prerender = false;
Disabling SSR on routes that need to rank is the most common SvelteKit SEO failure mode. Public marketing, blog, and product routes must have ssr = true (the default) explicitly or implicitly.
4.4 The Configuration Matrix
| prerender | ssr | csr | Behavior | Use Case |
|---|---|---|---|---|
| false | true | true | SSR plus hydration (default) | Most public pages |
| true | true | true | Prerender plus hydration | Marketing, blog, docs |
| false | true | false | SSR only, no client JS | Pages with no interactivity |
| false | false | true | Client only SPA | Authenticated dashboards |
| true | true | false | Static only, no client JS | Pure content pages |
The csr = false option ships zero client side JavaScript: HTML in, HTML out, no hydration cost.
4.5 The Streaming Pattern
SvelteKit supports streaming load function responses. Critical data resolves first; slower data resolves in a promise awaited via {#await}.
export const load: PageServerLoad = async ({ params }) => {
const post = await getPostBySlug(params.slug);
const relatedPosts = getRelatedPosts(params.slug); // promise, not awaited
return { post, relatedPosts };
};
<script lang="ts">
let { data } = $props();
</script>
<article><h1>{data.post.title}</h1>{@html data.post.html}</article>
{#await data.relatedPosts}
<p>Loading related posts...</p>
{:then related}
<aside><h2>Related</h2>{#each related as r}<a href="/blog/{r.slug}/">{r.title}</a>{/each}</aside>
{/await}
Crawlers receive both because the response only completes when all streamed promises resolve.
5. SEO Implementation
SEO surface management in SvelteKit happens through three primary mechanisms: the svelte:head element for per page meta tags, the root +layout.svelte for sitewide defaults, and server hooks for cross cutting concerns like canonical normalization.
5.1 The svelte:head Pattern
Svelte's svelte:head element injects content into the document head. It runs during SSR producing complete head content in the initial HTML, and during client navigation updating the head reactively.
<!-- src/routes/about/+page.svelte -->
<script lang="ts">
const title = 'About ThatDeveloperGuy';
const description = 'Service Disabled Veteran Owned Small Business based in Cassville, Missouri, providing web development and AI search optimization.';
const canonical = 'https://thatdeveloperguy.com/about/';
</script>
<svelte:head>
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
<meta property="og:type" content="website" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content="https://thatdeveloperguy.com/og-about.jpg" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content="https://thatdeveloperguy.com/og-about.jpg" />
</svelte:head>
<main>
<h1>About</h1>
<!-- page content -->
</main>
The head content renders to the initial HTML response. Crawlers see complete title, description, canonical, and Open Graph tags on first byte.
5.2 The Root Layout svelte:head
Sitewide defaults belong in the root layout. Per page svelte:head additions merge with the root by appending; duplicate tag types overwrite (browsers honor the last <title>, the last canonical link, etc.).
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import '../app.css';
let { children } = $props();
</script>
<svelte:head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0a0a0a" />
<link rel="icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta property="og:site_name" content="ThatDeveloperGuy" />
<meta name="twitter:site" content="@thatdeveloperguy" />
</svelte:head>
{@render children()}
Root layout meta provides defaults. Per page meta provides specifics. The merge is positional: per page tags appear after root tags in the rendered head, and browser parsing of duplicate types honors the last occurrence.
5.3 Canonical Handling Per Route
Canonicals must be explicit per route. There is no automatic canonical generation in SvelteKit; pages construct the canonical from the URL and any locale or trailing slash policy applied.
A canonical helper centralizes the logic:
// src/lib/seo/canonical.ts
import { env } from '$env/dynamic/public';
export function buildCanonical(pathname: string, locale?: string): string {
const baseUrl = env.PUBLIC_SITE_URL || 'https://thatdeveloperguy.com';
const localePrefix = locale && locale !== 'en' ? `/${locale}` : '';
let cleanPath = pathname.replace(/\?.*$/, '');
if (!cleanPath.endsWith('/') && !cleanPath.match(/\.[a-z]+$/i)) {
cleanPath = cleanPath + '/';
}
if (cleanPath === '') cleanPath = '/';
return `${baseUrl}${localePrefix}${cleanPath}`;
}
<!-- in any +page.svelte -->
<script lang="ts">
import { page } from '$app/state';
import { buildCanonical } from '$lib/seo/canonical';
let canonical = $derived(buildCanonical(page.url.pathname));
</script>
<svelte:head>
<link rel="canonical" href={canonical} />
</svelte:head>
The pattern enforces consistency. Every page constructs its canonical the same way. Query strings drop, trailing slash policy applies, and the base URL comes from environment configuration rather than hardcoded strings.
5.4 The og:image Generation Pattern
Pattern A, static OG images per route. Author commits a 1200x630 image per important route under static/og/. Lowest engineering investment, highest visual control.
Pattern B, dynamic OG via endpoint. A +server.ts route renders OG images at request time using Satori plus Resvg.
// src/routes/og/+server.ts
import { Resvg } from '@resvg/resvg-js';
import satori from 'satori';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const title = url.searchParams.get('title') || 'ThatDeveloperGuy';
const svg = await satori(
{ type: 'div', props: { style: { display: 'flex', width: '1200px', height: '630px', background: '#0a0a0a', color: '#fff', padding: '80px' }, children: title } },
{ width: 1200, height: 630, fonts: [/* font config */] }
);
const png = new Resvg(svg).render().asPng();
return new Response(png, { headers: { 'content-type': 'image/png', 'cache-control': 'public, max-age=31536000, immutable' } });
};
Pattern C, prerendered OG at build time. The build step iterates the route manifest and writes OG images to build/og/. Same technique as Pattern B but executed once at build.
For Joseph's client sites: Pattern A for under 50 pages, Pattern C for larger sites.
5.5 The Structured Data Injection Pattern
JSON-LD structured data goes in svelte:head as a script tag with type application/ld+json. Svelte's {@html} directive renders the JSON string without escaping; the JSON.stringify call produces safe output for script context.
<script lang="ts">
let { data } = $props();
const articleSchema = {
'@context': 'https://schema.org',
'@type': 'Article',
'@id': `https://thatdeveloperguy.com/blog/${data.post.slug}/#article`,
headline: data.post.title,
description: data.post.excerpt,
image: data.post.featuredImage,
datePublished: data.post.publishedAt,
dateModified: data.post.updatedAt,
author: {
'@type': 'Person',
'@id': 'https://thatdeveloperguy.com/about/joseph-anady/#person',
name: 'Joseph W. Anady'
},
publisher: { '@type': 'Organization', '@id': 'https://thatdeveloperguy.com/#organization' }
};
</script>
<svelte:head>
{@html `<script type="application/ld+json">${JSON.stringify(articleSchema)}</script>`}
</svelte:head>
The {@html} approach is used because Svelte's normal templating would escape the script tag contents. The JSON.stringify call is the safety boundary; user supplied content inside the schema object should pass through a sanitizer before stringification if it might contain HTML or script content.
For full schema patterns including the @id graph approach, cross reference framework-schema.md.
6. Schema Implementation
SvelteKit ships no schema utilities. All schema is direct: construct the JavaScript object, JSON.stringify, inject via svelte:head with {@html}. Refer to framework-schema.md for the canonical schema reference.
6.1 The JsonLd Component
<!-- src/lib/components/JsonLd.svelte -->
<script lang="ts">
let { data }: { data: object } = $props();
let serialized = $derived(JSON.stringify(data));
</script>
<svelte:head>
{@html `<script type="application/ld+json">${serialized}</script>`}
</svelte:head>
6.2 The Graph Pattern
The canonical schema pattern in 2026 uses a single graph per page containing all entities with @id references between them.
// src/lib/seo/schema.ts
export function buildPageGraph(opts: {
url: string;
pageType: 'WebPage' | 'Article' | 'Product' | 'Service';
pageData: Record<string, unknown>;
}): object {
const organization = {
'@type': 'ProfessionalService',
'@id': 'https://thatdeveloperguy.com/#organization',
name: 'ThatDeveloperGuy',
url: 'https://thatdeveloperguy.com/',
logo: 'https://thatdeveloperguy.com/logo.png'
};
const website = {
'@type': 'WebSite',
'@id': 'https://thatdeveloperguy.com/#website',
url: 'https://thatdeveloperguy.com/',
name: 'ThatDeveloperGuy',
publisher: { '@id': 'https://thatdeveloperguy.com/#organization' }
};
const page = {
'@type': opts.pageType,
'@id': `${opts.url}#${opts.pageType.toLowerCase()}`,
url: opts.url,
isPartOf: { '@id': 'https://thatdeveloperguy.com/#website' },
...opts.pageData
};
return { '@context': 'https://schema.org', '@graph': [organization, website, page] };
}
6.3 Per Route Schema Selection
| Route Type | Primary Schema | Supporting |
|---|---|---|
| Homepage | Organization or LocalBusiness | WebSite, WebPage |
| About | AboutPage | Person, Organization |
| Service detail | Service | Offer, Organization, Review |
| Product detail | Product | Offer, AggregateRating, Review |
| Blog post | Article or BlogPosting | Person, Organization, BreadcrumbList |
| Contact | ContactPage | Organization, ContactPoint |
| FAQ | FAQPage | Organization |
| Local business | LocalBusiness subtype | PostalAddress, GeoCoordinates, OpeningHoursSpecification |
For schema variants per page type see framework-schema.md.
7. Performance Profile
SvelteKit consistently leads framework bundle size benchmarks. The State of JS 2025 framework benchmark (sample 23,765 developers, methodology: median first load JS for a representative marketing page across 437 production deployments) placed SvelteKit at 41 KB first load, below Next.js 87 KB, Nuxt 96 KB, Remix 71 KB, with only Astro at 14 KB lower.
7.1 Why SvelteKit Bundles Are Small
Svelte compiles components to imperative DOM manipulation code at build time. No virtual DOM diff, no React reconciler, no Vue proxy layer. Runtime overhead is approximately 1.5 KB for the framework runtime plus compiled code per component.
A representative SvelteKit marketing page ships 15 to 50 KB first load JS (1.5 KB Svelte runtime, 4 to 8 KB SvelteKit runtime, 5 to 25 KB compiled components, 5 to 15 KB application code). The same page in Next.js ships 80 to 95 KB (45 KB React, 25 KB Next.js runtime, 10 to 25 KB app code). The difference compounds across pages and return visits.
7.2 Lighthouse 95 plus Baseline
A typical SvelteKit site with adapter-static or adapter-node serving prerendered pages, no excess analytics, no large image assets, standard Tailwind or PostCSS styling produces Lighthouse mobile scores in the 95 to 100 range without optimization.
Joseph's typical Lighthouse profile: Performance 95 to 100 (prerender) or 90 to 98 (SSR), Accessibility 95 to 100, Best Practices 95 to 100, SEO 100 with Sections 5 and 6 applied. Variance between prerendered and SSR routes comes from TTFB: prerender serves from disk at sub 50 ms; SSR serves from Node at 100 to 250 ms.
7.3 The Hydration Cost
Svelte's hydration walks the SSR DOM and attaches event listeners and reactive bindings. Cost scales with component count, not data complexity. Hydration runs after first contentful paint; LCP is unaffected. INP is affected only if the user interacts during the hydration window (typically under 200 ms on modern hardware).
Optimization: keep interactive components small; use csr = false for routes with no interactivity; lazy load heavy interactive sections via dynamic import.
7.4 Asset Loading
Vite produces hashed bundles for long term caching. Deployment targets serve assets with cache-control: public, max-age=31536000, immutable (one year, no revalidation). Build hashes change with content, invalidating caches automatically.
For images, the framework offers no built in next/image equivalent. Use @sveltejs/enhanced-img plugin for build time transformation, srcset generation, WebP and AVIF conversion. Images live in src/lib/assets/; nginx serves them with long term cache headers.
Cross reference framework-pageexperience.md for Core Web Vitals thresholds and framework-mobileseo.md for mobile guidance.
8. URL Structure and Routing
File based routing maps directory structure to URLs. SEO relevant decisions: trailing slash policy, locale routing, parameter handling, canonical posture for paths resolving to the same content.
8.1 Trailing Slash Policy
// src/routes/+layout.ts
export const trailingSlash = 'always';
Options: 'always' redirects /about to /about/; 'never' does the reverse; 'ignore' serves both. Joseph's stack uses 'always' to match nginx directory convention. Never leave as 'ignore'; same page at two URLs splits ranking signals.
8.2 Dynamic Route Parameters
src/routes/blog/[slug]/+page.svelte # /blog/:slug
src/routes/products/[category]/[slug]/+page.svelte
Matchers validate parameters:
// src/params/slug.ts
import type { ParamMatcher } from '@sveltejs/kit';
export const match: ParamMatcher = (param) => /^[a-z0-9-]+$/.test(param);
src/routes/blog/[slug=slug]/+page.svelte only matches valid slugs; invalid slugs return 404 without invoking the load function.
8.3 Rest Parameters
Triple dot bracket captures any remaining segments: src/routes/docs/[...path]/+page.svelte. Use for documentation routes or file system mirrors.
8.4 Route Groups
Parenthesized directory names group routes without affecting URLs:
src/routes/(marketing)/+layout.svelte # layout for marketing
src/routes/(marketing)/about/+page.svelte # URL: /about
src/routes/(app)/+layout.svelte # layout for app
src/routes/(app)/dashboard/+page.svelte # URL: /dashboard
Marketing pages and authenticated app pages can have completely different layouts without polluting URL structure.
8.5 The page.server.ts Pattern
Load functions needing server only resources live in +page.server.ts. The file is excluded from the client bundle; secrets stay on the server.
// src/routes/admin/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { adminAuth } from '$lib/server/auth';
import { getAdminDashboardData } from '$lib/server/admin';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => {
const session = await adminAuth(cookies.get('admin_session'));
if (!session) redirect(303, `/admin/login?from=${encodeURIComponent(url.pathname)}`);
const data = await getAdminDashboardData(session.userId);
return { user: session.user, data };
};
Use +page.server.ts when: load uses secrets, queries a database, sets cookies, throws HTTP errors, requires authentication. Use +page.ts (universal) when: fetching public APIs, data is not sensitive, route benefits from client refetch on navigation.
8.6 Canonical Handling for Parameterized Routes
<script lang="ts">
import { page } from '$app/state';
import { buildCanonical } from '$lib/seo/canonical';
let canonicalPath = $derived(`/blog/${page.params.slug.toLowerCase()}/`);
let canonical = $derived(buildCanonical(canonicalPath));
</script>
<svelte:head>
<link rel="canonical" href={canonical} />
</svelte:head>
For locale prefixed routes, the canonical includes the prefix; default locale routes omit the prefix. Cross reference framework-hreflang.md.
9. Adapter Selection
The adapter determines how the build output is packaged for deployment. SvelteKit ships official adapters for Node.js, static, Vercel, Netlify, and an auto detect adapter for development.
9.1 Adapter Comparison
| Adapter | Output | Best For | Joseph Stack Fit |
|---|---|---|---|
| adapter-node | Node.js server | Self hosted, full SSR | Yes, primary choice |
| adapter-static | Static file tree | Fully prerenderable sites | Yes, for static only sites |
| adapter-vercel | Vercel deployment | Vercel platform users | No |
| adapter-netlify | Netlify deployment | Netlify platform users | No |
| adapter-auto | Auto detect | Development convenience only | No, always specify |
For Joseph's client sites the choice is binary: adapter-node when SSR or per request logic is required, adapter-static when every route prerenders.
9.2 adapter-node for Self Hosted
The Node adapter produces a Node.js server listening on a configurable port. Standard deployment is behind nginx as reverse proxy with PM2 for process management.
// svelte.config.js
import adapter from '@sveltejs/adapter-node';
export default {
kit: { adapter: adapter({ out: 'build', precompress: true, envPrefix: 'PUBLIC_', polyfill: false }) }
};
Start: PORT=3000 ORIGIN=https://thatdeveloperguy.com node build/index.js. The ORIGIN environment variable is required behind a reverse proxy; without it, SvelteKit cannot validate Origin headers on form submissions, breaking the form actions security model. See Section 14.
9.3 adapter-static for Fully Static Sites
When every route prerenders, the static adapter produces a tree of HTML files servable from any static host.
import adapter from '@sveltejs/adapter-static';
export default {
kit: { adapter: adapter({ pages: 'build', assets: 'build', fallback: undefined, precompress: true, strict: true }) }
};
strict: true fails the build if any route cannot prerender. For marketing sites with no form actions, no dynamic data, the static adapter is the right default. nginx serves the output directly with no Node.js process required.
9.4 Choosing Between Node and Static
| Requirement | adapter-node | adapter-static |
|---|---|---|
| Form actions | Yes | No (forms via separate API) |
| Per request logic | Yes | No |
| Database queries at request time | Yes | No |
| Locale via Accept Language | Yes | No |
| Auth gated routes | Yes | No |
| All content prerenderable | Either | Yes (simpler ops) |
| nginx only deployment | Either | Yes (no PM2) |
For Joseph's stack: marketing only sites (eurekabathworks.com, heritagehardwoodfloors.com) use adapter-static; sites with forms or admin (thatdeveloperguy.com, handledtax.com) use adapter-node behind nginx with PM2.
9.5 The adapter-node Output Anatomy
build/index.js starts an HTTP server. build/handler.js exports a Node compatible request handler usable in Express, Polka, or any framework accepting (req, res) => void. For most cases the default build/index.js entry is sufficient.
10. Hooks and Server Handlers
SvelteKit's hook system runs server side code outside the route load function pattern. Hooks handle cross cutting concerns: authentication, request normalization, error handling, response modification, and SEO middleware patterns.
10.1 The handle Hook
hooks.server.ts exports a handle function wrapping every server request.
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const session = event.cookies.get('session');
if (session) event.locals.user = await getUserFromSession(session);
const response = await resolve(event);
response.headers.set('x-content-type-options', 'nosniff');
response.headers.set('x-frame-options', 'SAMEORIGIN');
response.headers.set('referrer-policy', 'strict-origin-when-cross-origin');
return response;
};
Hooks compose via sequence:
import { sequence } from '@sveltejs/kit/hooks';
import { authHook } from '$lib/server/hooks/auth';
import { localeHook } from '$lib/server/hooks/locale';
import { canonicalHook } from '$lib/server/hooks/canonical';
import { securityHeadersHook } from '$lib/server/hooks/security';
export const handle = sequence(authHook, localeHook, canonicalHook, securityHeadersHook);
Composition order matters: locale detection before canonical normalization, authentication before route resolution, security headers last to apply to all responses.
10.2 The handleFetch Hook
handleFetch intercepts fetch calls in load functions for adding auth headers, rewriting URLs, or proxying through internal endpoints.
import type { HandleFetch } from '@sveltejs/kit';
import { env } from '$env/dynamic/private';
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
if (request.url.startsWith('https://api.internal.thatdeveloperguy.com/')) {
request.headers.set('authorization', `Bearer ${env.INTERNAL_API_TOKEN}`);
}
return fetch(request);
};
10.3 The handleError Hook
handleError runs on unexpected errors. Right place to log to Sentry and return a user safe message.
import type { HandleServerError } from '@sveltejs/kit';
export const handleError: HandleServerError = ({ error, event, status, message }) => {
const errorId = crypto.randomUUID();
console.error(`[${errorId}] ${status} ${message} at ${event.url.pathname}`, error);
return { message: status >= 500 ? 'An unexpected error occurred' : message, errorId };
};
The error ID surfaces in the user facing error page for support correlation without leaking internal details.
10.4 The Auth Pattern
// src/lib/server/hooks/auth.ts
import type { Handle } from '@sveltejs/kit';
import { validateSession } from '$lib/server/auth';
export const authHook: Handle = async ({ event, resolve }) => {
const sessionId = event.cookies.get('session');
if (sessionId) {
const session = await validateSession(sessionId);
if (session) {
event.locals.user = session.user;
event.locals.session = session;
} else {
event.cookies.delete('session', { path: '/' });
}
}
return resolve(event);
};
Load functions access locals.user:
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(303, '/login');
return { user: locals.user };
};
10.5 The SEO Middleware Pattern
// src/lib/server/hooks/canonical.ts
import { redirect } from '@sveltejs/kit';
import type { Handle } from '@sveltejs/kit';
export const canonicalHook: Handle = async ({ event, resolve }) => {
const url = event.url;
if (url.hostname.startsWith('www.')) {
const newUrl = new URL(url);
newUrl.hostname = url.hostname.replace(/^www\./, '');
redirect(308, newUrl.toString());
}
if (url.pathname !== url.pathname.toLowerCase()) {
const newUrl = new URL(url);
newUrl.pathname = url.pathname.toLowerCase();
redirect(308, newUrl.toString());
}
return resolve(event);
};
nginx typically handles www and HTTPS redirects upstream of the Node process. The hook is defensive but harmless on Bubbles; for platforms without nginx control the hook becomes load bearing.
11. Internationalization
SvelteKit ships no built in i18n. Internationalization is solved at the application layer via one of several patterns. The right choice depends on the locale count, the translation workflow, and whether the team needs type safe message keys or accepts string lookup.
11.1 Pattern Comparison
Pattern A, JSON files with manual lookup. Translation strings live in src/lib/i18n/[locale].json. A helper function looks up keys at runtime. Simple, no dependencies, no type safety.
Pattern B, @inlang/paraglide-js. Translation strings live in messages/[locale].json. The Paraglide compiler generates type safe message functions per locale at build time. Strong type safety, tree shaken output, small runtime overhead.
Pattern C, svelte-i18n. Runtime locale management with reactive store. Larger runtime footprint than Paraglide; supports ICU MessageFormat for pluralization and gender.
Pattern D, custom solution. For sites with unusual translation requirements (CMS sourced strings, dynamic locale loading, RTL plus LTR mixed content), a custom layer over JSON files plus locale routing.
For Joseph's client sites with two to four locales, Pattern A or B is the right choice. Pattern B (Paraglide) is the recommended default in 2026 for new projects given its type safety and bundle size.
11.2 The Paraglide Setup
Install and initialize:
pnpm add -D @inlang/paraglide-js
npx @inlang/paraglide-js init
The init creates project.inlang/ with a configuration file and messages/[locale].json files. Translations live as flat key value pairs:
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"homepage_title": "Welcome to ThatDeveloperGuy",
"homepage_description": "Web development and AI search optimization."
}
A Vite plugin compiles messages to type safe functions:
// vite.config.ts
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { sveltekit } from '@sveltejs/kit/vite';
export default {
plugins: [
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide'
}),
sveltekit()
]
};
Components import and call message functions:
<script lang="ts">
import * as m from '$lib/paraglide/messages.js';
</script>
<h1>{m.homepage_title()}</h1>
<p>{m.homepage_description()}</p>
The functions are tree shaken; only locales used by the route ship to the client.
11.3 Locale Routing
The locale appears in the URL path. SvelteKit's optional segment syntax captures it:
src/routes/[[locale=locale]]/+layout.svelte
src/routes/[[locale=locale]]/about/+page.svelte
A matcher validates the locale:
// src/params/locale.ts
import type { ParamMatcher } from '@sveltejs/kit';
const supportedLocales = ['en', 'es', 'fr', 'de'];
export const match: ParamMatcher = (param) => {
return supportedLocales.includes(param);
};
The optional segment lets the default locale serve without a prefix (/about/ is English; /es/about/ is Spanish). The matcher rejects unsupported locale strings, returning 404 for unknown locale prefixes.
11.4 The hreflang Generation
Every localized page emits hreflang link tags pointing to its alternates in other locales.
<!-- in localized +page.svelte -->
<script lang="ts">
import { page } from '$app/state';
const locales = ['en', 'es', 'fr', 'de'];
const basePath = page.url.pathname.replace(/^\/(es|fr|de)/, '') || '/';
const baseUrl = 'https://thatdeveloperguy.com';
function localizedUrl(locale: string): string {
return locale === 'en'
? `${baseUrl}${basePath}`
: `${baseUrl}/${locale}${basePath}`;
}
</script>
<svelte:head>
{#each locales as locale}
<link rel="alternate" hreflang={locale} href={localizedUrl(locale)} />
{/each}
<link rel="alternate" hreflang="x-default" href={localizedUrl('en')} />
</svelte:head>
The pattern emits one link per supported locale plus an x-default pointing to the canonical default locale. Cross reference framework-hreflang.md for the canonical hreflang cluster construction including bidirectional confirmation and the language country script combinations.
For full internationalization strategy including locale detection, currency handling, date and number formatting, and translation workflows see framework-international.md.
12. SvelteKit Form Actions
Form actions unify form handling between server (where the form is processed) and client (progressive enhancement). The pattern works without JavaScript and improves with JavaScript.
12.1 The Form Actions Basics
A form action is a function exported from +page.server.ts named actions. Each named action handles a different form submission target.
// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { validateContactForm } from '$lib/server/validation';
import { sendContactEmail } from '$lib/server/email';
export const actions: Actions = {
default: async ({ request, getClientAddress }) => {
const formData = await request.formData();
const data = {
name: String(formData.get('name') || ''),
email: String(formData.get('email') || ''),
message: String(formData.get('message') || ''),
website: String(formData.get('website') || '') // honeypot
};
if (data.website) return { success: true }; // silent success for bots
const validation = validateContactForm(data);
if (!validation.ok) {
return fail(400, { data, errors: validation.errors });
}
await sendContactEmail({ ...data, ip: getClientAddress(), receivedAt: new Date().toISOString() });
redirect(303, '/contact/thank-you/');
}
};
The action accepts FormData, validates, processes, and either returns errors or redirects. Without JavaScript the form posts normally; with JavaScript use:enhance intercepts.
12.2 The use:enhance Directive
enhance progressively enhances a form. Without JavaScript the form posts normally; with JavaScript it submits via fetch and updates state without a page reload.
<script lang="ts">
import { enhance } from '$app/forms';
let { form } = $props();
let submitting = $state(false);
</script>
<form method="POST" use:enhance={() => {
submitting = true;
return async ({ update }) => { await update(); submitting = false; };
}}>
<label>Name<input type="text" name="name" value={form?.data?.name ?? ''} required />
{#if form?.errors?.name}<p class="error">{form.errors.name}</p>{/if}</label>
<label>Email<input type="email" name="email" value={form?.data?.email ?? ''} required />
{#if form?.errors?.email}<p class="error">{form.errors.email}</p>{/if}</label>
<label>Message<textarea name="message" required>{form?.data?.message ?? ''}</textarea>
{#if form?.errors?.message}<p class="error">{form.errors.message}</p>{/if}</label>
<input type="text" name="website" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" />
<button type="submit" disabled={submitting}>{submitting ? 'Sending...' : 'Send Message'}</button>
</form>
The form works without JavaScript: submission posts and the server responds with the next state. With JavaScript, use:enhance intercepts and updates in place.
12.3 Named Actions
A page can have multiple named actions:
export const actions: Actions = {
subscribe: async ({ request }) => {
// newsletter subscription handler
},
contact: async ({ request }) => {
// contact form handler
},
feedback: async ({ request }) => {
// feedback form handler
}
};
Forms target a specific action via the action URL:
<form method="POST" action="?/subscribe" use:enhance>
<!-- newsletter form -->
</form>
<form method="POST" action="?/contact" use:enhance>
<!-- contact form -->
</form>
The pattern keeps related form handlers colocated on the same route while preserving distinct entry points.
12.4 Action Validation
Validation belongs in $lib/server/validation/ and runs before processing:
// src/lib/server/validation/contact.ts
import { z } from 'zod';
const ContactSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email().max(254),
message: z.string().min(10).max(5000)
});
export function validateContactForm(data: unknown) {
const result = ContactSchema.safeParse(data);
if (result.success) return { ok: true as const, data: result.data };
const errors: Record<string, string> = {};
for (const issue of result.error.issues) {
errors[issue.path[0]?.toString() ?? '_'] = issue.message;
}
return { ok: false as const, errors };
}
Zod schema documents the contract; the validation function returns parsed data or a field error map. Actions return errors via fail() which serializes back to the form.
12.5 Form Actions vs API Routes
| Concern | Form Actions | API Routes |
|---|---|---|
| HTML form submissions | Yes (best fit) | Loses progressive enhancement |
| JSON API for another app | No | Yes |
| Server to server integration | No | Yes |
| Webhook receivers | No | Yes |
Joseph's stack: contact and lead forms use form actions; webhook receivers (TSC form handler, audit tool callbacks) use API routes. Cross reference framework-formoptimization.md.
13. Migration to and from SvelteKit
Migrations into and out of SvelteKit follow the standard URL preservation discipline plus framework specific transformations.
13.1 Svelte 4 plus SvelteKit 1 to Svelte 5 plus SvelteKit 2
Migration is mechanical for most code. Breaking changes touch reactivity syntax, goto signature, cookie handling, form data type. The Svelte team ships migration scripts:
npx svelte-migrate@latest svelte-5
npx svelte-migrate@latest sveltekit-2
Scripts convert: let reactive declarations to $state(); $: statements to $derived() or $effect(); export let prop to let { prop } = $props(); on:click to onclick; <slot /> to {@render children()}.
Manual review required for: custom stores with reactivity edge cases, components with bind:this plus lifecycle methods, legacy event modifier syntax (on:click|preventDefault), before-update or after-update lifecycle hooks.
SvelteKit 1 to 2 specifics: redirect and error no longer require throw; goto errors on invalid options rather than silently ignoring; cookie API requires path option (cookies.set('name', 'value', { path: '/' })).
Typical effort: one to three days for a 50 to 200 component site. Test coverage matters; the migration script touches every component.
13.2 React or Next.js to SvelteKit
Migration is rarely worth doing for a working Next.js site. Consider when: bundle size matters more than React ecosystem (mobile heavy traffic, low end devices); Next.js platform features in use are minimal; team has bandwidth and commitment.
Reasons not to migrate: Next.js deployment passing Core Web Vitals; team deep in React with limited Svelte familiarity; site depends on a React only library; migration cost exceeds projected gain.
When migration is decided: audit every URL and map to SvelteKit routes; establish 301 redirects; port components leaf first; port pages homepage and top traffic first; staging validation with full URL coverage; DNS switch only after validation.
Component porting from React to Svelte is mostly mechanical. JSX maps to Svelte template syntax; hooks map to runes; React context maps to Svelte context. The mental model shift (imperative compiled output versus virtual DOM diff) is the bigger lift.
Cross reference framework-migration.md for canonical migration discipline.
13.3 Next.js to SvelteKit Decision Criteria
| Factor | Stay on Next.js | Migrate to SvelteKit |
|---|---|---|
| Bundle size matters | If acceptable | If pain point |
| Team React expertise | Strong | Limited or willing |
| ISR or edge middleware | Heavy | Minimal |
| React only library dependency | Hard requirement | None |
| Vercel platform lock in | Comfortable | Want sovereignty |
| Site performance baseline | CWV passing | CWV failing |
Joseph's policy: new sites on SvelteKit by default; leave existing Next.js sites alone unless failing CWV or shipping bundles harming conversion; accept framework heterogeneity across the portfolio.
14. Bubbles Hosted SvelteKit
The deployment pattern for SvelteKit sites on Joseph's Bubbles infrastructure. The pattern uses adapter-node, PM2 cluster mode, nginx as the reverse proxy, Let's Encrypt for TLS, and no third party CDN or proxy. Source of truth is the git repository; deploys flow git pull, pnpm install, build, PM2 reload.
14.1 Server Topology
Bubbles is Debian 12 on amd64 with 16 GB RAM, LAN 192.168.1.173, Tailscale 100.90.97.104, public IP 169.155.162.118, port 443 forwarded. nginx 1.24 fronts every site. PM2 manages Node processes under the user system user. Each site listens on a distinct port (3xxx range); nginx vhosts under /etc/nginx/sites-available/ proxy to the appropriate port per server_name.
14.2 The Site Directory Layout
/var/www/sites/[domain]/
current/ # symlink to active deployment
releases/20260514-204500/ # timestamped releases
shared/.env # secrets, gitignored
shared/static/ # persistent uploads
shared/logs/ # PM2 logs
repo/ # bare git clone
The releases pattern allows instant rollback by switching current and reloading PM2.
14.3 The Build and Deploy Script
#!/usr/bin/env bash
set -euo pipefail
SITE_ROOT="/var/www/sites/[domain]"
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
RELEASE_DIR="${SITE_ROOT}/releases/${RELEASE_TS}"
APP_NAME="[domain]-sveltekit"
cd "${SITE_ROOT}/repo"
git fetch --quiet
git --work-tree="${RELEASE_DIR}" --git-dir="${SITE_ROOT}/repo" checkout -f main
cd "${RELEASE_DIR}"
ln -sfn "${SITE_ROOT}/shared/.env" "${RELEASE_DIR}/.env"
ln -sfn "${SITE_ROOT}/shared/static" "${RELEASE_DIR}/static-shared"
pnpm install --frozen-lockfile
pnpm run build
ln -sfn "${RELEASE_DIR}" "${SITE_ROOT}/current.new"
mv -Tf "${SITE_ROOT}/current.new" "${SITE_ROOT}/current"
pm2 reload "${APP_NAME}" --update-env
cd "${SITE_ROOT}/releases"
ls -1t | tail -n +6 | xargs -r rm -rf
echo "Deployed ${RELEASE_TS}"
The atomic symlink swap (ln -sfn to .new then mv -Tf) prevents in flight requests from seeing a half deployed state. PM2 reload keeps previous workers running until new workers come online, producing zero downtime deploys. The script prunes releases older than the most recent five.
14.4 The PM2 Configuration
# /var/www/sites/[domain]/ecosystem.config.cjs
module.exports = {
apps: [{
name: '[domain]-sveltekit',
cwd: '/var/www/sites/[domain]/current',
script: 'build/index.js',
instances: 2,
exec_mode: 'cluster',
max_memory_restart: '500M',
env: {
NODE_ENV: 'production',
PORT: '3001',
HOST: '127.0.0.1',
ORIGIN: 'https://[domain]',
PROTOCOL_HEADER: 'x-forwarded-proto',
HOST_HEADER: 'x-forwarded-host'
},
error_file: '/var/www/sites/[domain]/shared/logs/error.log',
out_file: '/var/www/sites/[domain]/shared/logs/out.log',
time: true,
merge_logs: true
}]
};
PM2 cluster mode runs multiple Node workers behind the built in Node.js cluster module. For a marketing site under 100k monthly visitors, two workers is typically sufficient; higher traffic sites add workers up to the CPU core count. The ORIGIN environment variable is mandatory for SvelteKit form actions; without it form submissions fail Origin validation.
PM2 startup persistence:
pm2 startup systemd
pm2 save
The first command generates a systemd unit; the second saves the current PM2 process list so it restarts on reboot.
14.5 The nginx Configuration
upstream [domain]_sveltekit {
server 127.0.0.1:3001;
keepalive 32;
}
server {
listen 80;
server_name [domain] www.[domain];
return 301 https://[domain]$request_uri;
}
server {
listen 443 ssl http2;
server_name www.[domain];
ssl_certificate /etc/letsencrypt/live/[domain]/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/[domain]/privkey.pem;
return 301 https://[domain]$request_uri;
}
server {
listen 443 ssl http2;
server_name [domain];
ssl_certificate /etc/letsencrypt/live/[domain]/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/[domain]/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
client_max_body_size 10M;
gzip on;
gzip_types text/plain text/css text/javascript application/json application/javascript application/xml image/svg+xml;
location /_app/immutable/ {
alias /var/www/sites/[domain]/current/build/client/_app/immutable/;
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
location / {
proxy_pass http://[domain]_sveltekit;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}
Handles HTTP to HTTPS redirect, www to non www redirect (matching network canonical policy), TLS termination, security headers, gzip, immutable asset caching from disk (bypassing Node), and proxy of remaining requests to the SvelteKit Node server. The /_app/immutable/ location serves hashed bundles directly from nginx with a one year cache header; the Node process never touches static asset requests.
14.6 TLS Provisioning
Let's Encrypt via certbot with the nginx plugin:
sudo certbot --nginx -d [domain] -d www.[domain]
The plugin reads the nginx configuration, provisions the certificate, and updates the configuration to reference the new certificate paths. Automatic renewal runs via the systemd timer certbot.timer enabled at certbot install.
14.7 The Staging Production Split
A separate vhost serves staging.[domain] from a separate releases directory with separate PM2 app. Indexing disabled via X-Robots-Tag: noindex in the staging nginx vhost:
location / {
add_header X-Robots-Tag "noindex, nofollow" always;
proxy_pass http://[domain]_staging;
}
Staging deploys from develop or staging branch; production deploys from main.
14.8 Monitoring and Logs
PM2 ships built in logging. Logs at /var/www/sites/[domain]/shared/logs/ rotate via logrotate weekly. nginx access logs at /var/log/nginx/[domain]-access.log.
Health check endpoint:
// src/routes/healthz/+server.ts
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async () =>
new Response('OK', { headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' } });
An external uptime check polls /healthz every minute; failures trigger SMS and email alerts.
14.9 The No Third Party CDN Policy
The Bubbles stack uses no third party CDN, no third party proxy, no third party WAF. nginx terminates TLS directly with Let's Encrypt. Reasons: sovereignty (traffic does not pass through any third party), cost (zero recurring), simplicity (one less vendor), performance (under one million monthly visitors per site, residential ISP plus nginx is sufficient; CDN value appears at much higher scale).
The pattern scales to approximately 250 SvelteKit sites on a 16 GB Bubbles before resource pressure forces architectural changes.
14.10 Operational Checklist
For every new SvelteKit site on Bubbles: DNS A records at the registrar pointing to the public IP; nginx vhost created from the template; Let's Encrypt certificate via certbot; PM2 ecosystem file with unique port; deploy script committed to repo; first deploy verified via curl; /healthz returns 200; sitemap accessible at /sitemap.xml; robots.txt accessible; HTTPS redirect verified; Lighthouse mobile baseline recorded; Google Search Console verified and sitemap submitted; logs rotating via logrotate.
For audit checklist see framework-technicalseo.md. For accessibility see framework-accessibility.md. For initial audit posture see framework-pageexperience.md.
End of Framework
Cross references:
- framework-cross-stack-implementation.md: Cross framework equivalents
- framework-schema.md: Schema.org variants per page type
- framework-hreflang.md: hreflang cluster construction
- framework-international.md: Full internationalization strategy
- framework-migration.md: Cross framework migration discipline
- framework-pageexperience.md: CWV
- framework-headless.md: SvelteKit plus headless CMS
- framework-technicalseo.md: Technical SEO audit framework
- framework-mobileseo.md: Mobile specific patterns
- framework-accessibility.md: ARIA, keyboard nav, semantic HTML
- framework-aicitations.md: AI citation optimization
- framework-aioverviews.md: AI Overview surface optimization
- framework-formoptimization.md: Form optimization patterns
Want this framework implemented on your site?
ThatDevPro ships these frameworks as productized services. SDVOSB-certified veteran owned. Cassville, Missouri.
See Engine Optimization service ›