Svelte #
Svelte is a compiler, not a runtime framework. Svelte code compiles into very small, fast vanilla JavaScript without a virtual DOM. Svelte 5 brings runes — a more explicit and powerful reactivity system. SvelteKit, the official meta-framework, adds routing, SSR/SSG, and adapters for various platforms.
For local development, SvelteKit + Vite provides very fast HMR. Docker ensures a consistent environment, and the build output (static or Node) can be served with nginx or a Node server.
This article covers Svelte 5 and SvelteKit with Docker Compose, including runes, stores, routing, and deployment strategies.
Prerequisites #
Make sure you have installed:
- Docker and Docker Compose (latest versions)
- Node.js 20+ (optional)
A SvelteKit project (bootstrapped with npx sv create):
my-svelte-app/
├── src/
│ ├── app.html
│ ├── app.d.ts
│ ├── lib/
│ │ ├── components/
│ │ ├── stores/
│ │ └── utils/
│ └── routes/
│ ├── +page.svelte
│ ├── +layout.svelte
│ └── api/
├── static/
├── package.json
├── svelte.config.js
├── vite.config.ts
├── Dockerfile
└── docker-compose.yml
Why Svelte Is Different #
flowchart LR
A[Source .svelte] --> B[Svelte Compiler]
B --> C[Vanilla JS]
C --> D[Browser]
style A fill:#ff3e00,color:#fff
style B fill:#ff3e00,color:#fff
style C fill:#40b3ff,color:#fff
style D fill:#40b3ff,color:#fffSvelte compiles components into imperative JavaScript that directly manipulates the DOM. The results:
| Aspect | Svelte | React/Vue |
|---|---|---|
| Runtime | ~5KB (runes) | 40-130KB |
| Bundle size | Very small | Larger |
| Reactivity | Compiler-level | Virtual DOM / Proxy |
| Learning curve | Gentle | Steeper |
Multi-Stage Dockerfile #
SvelteKit builds with an adapter produce different output depending on the target:
- adapter-static — static HTML for nginx
- adapter-node — a Node server for SSR/ISR
- adapter-auto — auto-detect platform (Vercel, Netlify, etc.)
# syntax=docker/dockerfile:1.6
# ---- Stage 1: Install dependencies ----
FROM node:20-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
# ---- Stage 2: Build SvelteKit ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build
# ---- Stage 3: Production with adapter-node ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
RUN addgroup --system --gid 1001 svelte
RUN adduser --system --uid 1001 svelte
# Copy the SvelteKit output
COPY --from=builder --chown=svelte:svelte /app/build ./build
COPY --from=builder --chown=svelte:svelte /app/package.json ./package.json
# Install only production dependencies
RUN npm ci --omit=dev && npm cache clean --force
USER svelte
EXPOSE 3000
CMD ["node", "build"]
svelte.config.js for adapter-node:
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: false,
envPrefix: ''
}),
},
};
export default config;
Static Export with adapter-static #
For blogs or static landing pages:
// svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html', // SPA mode
precompress: false,
strict: true
}),
},
};
# Final stage for static
FROM nginx:1.27-alpine
COPY --from=builder /app/build /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
nginx.conf for static SvelteKit:
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Cache SvelteKit assets
location /_app/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback for client-side routing
location / {
try_files $uri $uri/ /index.html;
}
}
docker-compose.yml for Development #
# docker-compose.yml
services:
web:
build:
context: .
dockerfile: Dockerfile.dev
image: my-svelte-app:dev
container_name: svelte-dev
command: npm run dev -- --host 0.0.0.0
ports:
- "5173:5173"
volumes:
- ./:/app
- /app/node_modules
- /app/.svelte-kit
environment:
- NODE_ENV=development
- DATABASE_URL=postgresql://app:pass@db:5432/myapp
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=myapp
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d myapp"]
interval: 10s
timeout: 5s
retries: 5
volumes:
db-data:
Dockerfile.dev:
FROM node:20-alpine
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]
Svelte 5 Runes #
Svelte 5 brings runes — an explicit reactivity system. Different from Svelte 4’s automatic reactivity, which relied on top-level let.
$state for Reactive Variables
#
<!-- src/lib/components/Counter.svelte -->
<script lang="ts">
let count = $state(0);
let name = $state('User');
function increment() {
count++;
}
</script>
<div>
<p>Hello, {name}!</p>
<p>Count: {count}</p>
<input bind:value={name} />
<button onclick={increment}>Add</button>
</div>
$derived for Computed Values
#
<script lang="ts">
let count = $state(0);
let doubleCount = $derived(count * 2);
let isEven = $derived(count % 2 === 0);
</script>
<p>Count: {count} (double: {doubleCount})</p>
<p>Status: {isEven ? 'Even' : 'Odd'}</p>
$effect for Side Effects
#
<script lang="ts">
import { onMount } from 'svelte';
let count = $state(0);
// Effect: runs automatically when count changes
$effect(() => {
console.log(`Count changed to: ${count}`);
document.title = `Count: ${count}`;
});
// Cleanup function
$effect(() => {
const interval = setInterval(() => {
count++;
}, 1000);
return () => clearInterval(interval);
});
</script>
<p>Count: {count}</p>
When to use$effectvsonMount? Use$effectwhen you need re-runs when state changes. UseonMountwhen the logic should only run once when the component mounts.
$props for Component Props
#
<!-- src/lib/components/UserCard.svelte -->
<script lang="ts">
interface User {
id: number;
name: string;
email: string;
}
interface Props {
user: User;
showEmail?: boolean;
onDelete?: (id: number) => void;
}
let { user, showEmail = true, onDelete }: Props = $props();
function handleDelete() {
onDelete?.(user.id);
}
</script>
<div class="user-card">
<h3>{user.name}</h3>
{#if showEmail}
<p>{user.email}</p>
{/if}
{#if onDelete}
<button onclick={handleDelete}>Delete</button>
{/if}
</div>
<style>
.user-card {
padding: 1rem;
border: 1px solid #ccc;
}
</style>
Usage:
<script lang="ts">
import UserCard from '$lib/components/UserCard.svelte';
let users = $state([
{ id: 1, name: 'Andi', email: '[email protected]' },
{ id: 2, name: 'Budi', email: '[email protected]' },
]);
function handleDelete(id: number) {
users = users.filter(u => u.id !== id);
}
</script>
{#each users as user (user.id)}
<UserCard {user} onDelete={handleDelete} />
{/each}
Stores for Global State #
SvelteKit provides the writable store from svelte/store for global state.
// src/lib/stores/cart.ts
import { writable, derived, get } from 'svelte/store';
import { browser } from '$app/environment';
interface CartItem {
id: number;
name: string;
price: number;
qty: number;
}
function createCartStore() {
// Persist to localStorage in the browser
const initial: CartItem[] = browser
? JSON.parse(localStorage.getItem('cart') ?? '[]')
: [];
const { subscribe, set, update } = writable<CartItem[]>(initial);
// Auto-save to localStorage
if (browser) {
subscribe((items) => {
localStorage.setItem('cart', JSON.stringify(items));
});
}
return {
subscribe,
add: (item: Omit<CartItem, 'qty'>) => {
update((items) => {
const existing = items.find(i => i.id === item.id);
if (existing) {
return items.map(i =>
i.id === item.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...items, { ...item, qty: 1 }];
});
},
remove: (id: number) => {
update((items) => items.filter(i => i.id !== id));
},
clear: () => set([]),
};
}
export const cart = createCartStore();
// Derived store
export const cartTotal = derived(cart, ($cart) =>
$cart.reduce((sum, i) => sum + i.price * i.qty, 0)
);
export const cartCount = derived(cart, ($cart) =>
$cart.reduce((sum, i) => sum + i.qty, 0)
);
Use it in a component with the $ auto-subscription:
<script lang="ts">
import { cart, cartTotal, cartCount } from '$lib/stores/cart';
</script>
<div>
<p>Items: {$cartCount} — Total: ${$cartTotal.toLocaleString('en-US')}</p>
<button onclick={() => cart.add({ id: 1, name: 'Product', price: 50000 })}>
Add
</button>
<button onclick={() => cart.clear()}>Clear</button>
</div>
The$prefix on a store enables auto-subscription. Without$, you mustsubscribemanually. This is powerful syntactic sugar from Svelte.
Routing with SvelteKit #
SvelteKit uses file-based routing in src/routes/:
src/routes/
├── +page.svelte # /
├── +layout.svelte # Root layout
├── about/
│ └── +page.svelte # /about
├── products/
│ ├── +page.svelte # /products
│ ├── +page.ts # Load function
│ └── [id]/
│ ├── +page.svelte # /products/:id
│ └── +page.ts # Dynamic load
├── api/
│ └── users/
│ └── +server.ts # /api/users endpoint
└── +error.svelte # Error page
Load function (server + client):
// src/routes/products/+page.ts
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ fetch }) => {
const res = await fetch('/api/products');
const products = await res.json();
return { products };
};
<!-- src/routes/products/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<h1>Products</h1>
<ul>
{#each data.products as product (product.id)}
<li>
<a href="/products/{product.id}">{product.name}</a>
<span>${product.price.toLocaleString('en-US')}</span>
</li>
{/each}
</ul>
Server-only load:
// src/routes/products/[id]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
export const load: PageServerLoad = async ({ params }) => {
const product = await db.product.findUnique({
where: { id: Number(params.id) },
});
if (!product) {
throw error(404, 'Product not found');
}
return { product };
};
Form actions (server-side mutation):
// src/routes/products/new/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { db } from '$lib/server/db';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const name = formData.get('name') as string;
const price = Number(formData.get('price'));
if (!name || isNaN(price)) {
return fail(400, { error: 'Name and price are required' });
}
await db.product.create({ data: { name, price } });
throw redirect(303, '/products');
},
};
<!-- src/routes/products/new/+page.svelte -->
<script lang="ts">
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<h1>New Product</h1>
{#if form?.error}
<p class="error">{form.error}</p>
{/if}
<form method="POST">
<input name="name" placeholder="Product name" required />
<input name="price" type="number" placeholder="Price" required />
<button type="submit">Save</button>
</form>
API endpoint:
// src/routes/api/users/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
export const GET: RequestHandler = async () => {
const users = await db.user.findMany();
return json(users);
};
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
const user = await db.user.create({ data: body });
return json(user, { status: 201 });
};
Hot Reload with Vite #
SvelteKit + Vite HMR works at every level:
.sveltechanges — instant HMR, state preserved+page.ts/+page.server.tschanges — page reload+layout.sveltechanges — all children reload+server.tschanges — the API endpoint restarts
For Docker, make sure the source bind mount and the .svelte-kit anonymous volume are set:
volumes:
- ./:/app
- /app/node_modules
- /app/.svelte-kit
Build and Run #
# Development
docker compose up --build
# Access: http://localhost:5173
# Production build (adapter-node)
docker build -t my-svelte-app:prod -f Dockerfile .
docker run -p 3000:3000 my-svelte-app:prod
# Production build (adapter-static)
docker build -t my-svelte-app:static -f Dockerfile.static .
docker run -p 8080:80 my-svelte-app:static
# Stop
docker compose down
Best Practices #
1. Choose the Right Adapter #
| Adapter | Use For |
|---|---|
adapter-static | Blogs, landing pages, documentation |
adapter-node | SSR, ISR, dynamic content |
adapter-auto | Deploying to Vercel/Netlify/Cloudflare |
2. Use Runes for New Code #
Svelte 5 runes are more explicit and powerful. For new projects, use $state, $derived, $effect, $props directly. For old projects, migrate gradually.
3. Separate Server vs Universal Code #
Code importing Node-only modules (like pg, fs) must be in +page.server.ts or $lib/server/. Code in +page.ts runs on BOTH server and client.
// src/lib/server/db.ts — server only
import { Pool } from 'pg';
export const pool = new Pool({ ... });
<script lang="ts">
// ❌ DON'T import server modules in +page.svelte
// import { pool } from '$lib/server/db';
// ✅ Use a load function
let { data } = $props();
</script>
4. Vite host: '0.0.0.0'
#
Mandatory in vite.config.ts or via the --host 0.0.0.0 flag. Without it, the dev server is unreachable from the host.
5. An Anonymous Volume for .svelte-kit
#
volumes:
- /app/.svelte-kit
Without it, type generation and build caches conflict with the host.
6. Form Actions for Mutations #
For form submissions, use form actions in +page.server.ts rather than manual fetch APIs. Simpler, automatic CSRF handling, and progressive enhancement ready.
7. Stores for Cross-Page State #
Cart, user auth, theme preferences — use stores in $lib/stores/. Auto-subscribe with the $ prefix in templates.
8. $effect for Cleanup
#
Always return a cleanup function from $effect to prevent memory leaks:
$effect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
return () => window.removeEventListener('resize', handler);
});
Troubleshooting #
HMR Not Working #
Make sure host: '0.0.0.0', the source bind mount, and the .svelte-kit anonymous volume are set.
“Cannot find module ‘$app/environment’” #
Install @sveltejs/kit and make sure svelte.config.js is valid. Restart the container.
Small Build Output but Production Errors #
Check +page.server.ts — maybe server code was accidentally bundled. Make sure server-only modules are imported from $lib/server/.
Adapter-node Port Conflict #
lsof -i :3000
Change the PORT environment variable or the Compose port mapping.
Svelte 5 Component Not Reactive #
Use runes ($state, $derived) instead of plain let. In Svelte 5, let is not automatically reactive.
Summary #
- Svelte is ideal for SPAs (adapter-static) or SSR (adapter-node). Small bundles, high performance.
- Multi-stage Dockerfiles differ per adapter: adapter-node uses
node build(Node server), adapter-static uses nginx.- Svelte 5 runes (
$state,$derived,$effect,$props) are modern reactivity — more explicit thanletin Svelte 4.- Stores with
writable+derivedin$lib/stores/. Auto-subscribe with the$prefix in templates.- SvelteKit routing with file-based routing in
src/routes/.+page.sveltefor UI,+page.ts/+page.server.tsfor data loading.- Form actions in
+page.server.tsfor mutations. Automatic CSRF handling, progressive enhancement, no JS required.- API endpoints with
+server.tsand HTTP method exports (GET,POST, etc.).- Adapter-node for SSR, adapter-static for SPAs, adapter-auto for specific platforms.
- Anonymous volumes for
node_modulesand.svelte-kitprevent host conflicts.- Server-only code goes in
$lib/server/or+page.server.ts— it must not be bundled to the client.- Vite hot reload works for
.svelte,+page.ts,+layout.svelte. State is preserved for components.- Use
$effectwith cleanup for side effects needing unsubscribe (event listeners, intervals).- Dev Dockerfiles are simple (Vite + npm), prod Dockerfiles are multi-stage (Node build + nginx/node runner).
- Troubleshooting: dead HMR (check host + volumes), module not found (restart the container), reactivity errors (use runes).
- Bundle size — Svelte is smaller than React/Vue thanks to compiler-level optimization.
- TypeScript with
lang="ts"in script tags. Type inference for props, state, and load functions.