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:#fff

Svelte compiles components into imperative JavaScript that directly manipulates the DOM. The results:

AspectSvelteReact/Vue
Runtime~5KB (runes)40-130KB
Bundle sizeVery smallLarger
ReactivityCompiler-levelVirtual DOM / Proxy
Learning curveGentleSteeper

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 $effect vs onMount? Use $effect when you need re-runs when state changes. Use onMount when 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 must subscribe manually. 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:

  • .svelte changes — instant HMR, state preserved
  • +page.ts / +page.server.ts changes — page reload
  • +layout.svelte changes — all children reload
  • +server.ts changes — 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 #

AdapterUse For
adapter-staticBlogs, landing pages, documentation
adapter-nodeSSR, ISR, dynamic content
adapter-autoDeploying 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 than let in Svelte 4.
  • Stores with writable + derived in $lib/stores/. Auto-subscribe with the $ prefix in templates.
  • SvelteKit routing with file-based routing in src/routes/. +page.svelte for UI, +page.ts/+page.server.ts for data loading.
  • Form actions in +page.server.ts for mutations. Automatic CSRF handling, progressive enhancement, no JS required.
  • API endpoints with +server.ts and HTTP method exports (GET, POST, etc.).
  • Adapter-node for SSR, adapter-static for SPAs, adapter-auto for specific platforms.
  • Anonymous volumes for node_modules and .svelte-kit prevent 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 $effect with 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.

← Previous: Vue 3   Next: Angular →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact