Building a Full-Stack Web App with SvelteKit 2: A Comprehensive Hands-On Guide

What You'll Build
In this tutorial, you'll build a full-stack notes management app using SvelteKit 2 — the official framework for building Svelte applications. By the end, you'll have a fully functional app with:
- File-based routing with nested layouts
- Server-side rendering (SSR) for blazing-fast performance
- Form Actions for processing forms without client-side JavaScript
- API routes for full CRUD operations
- Data storage using SQLite via
better-sqlite3 - TypeScript fully integrated
- Responsive design with modern CSS
Time required: 60-90 minutes
Prerequisites
Before starting, make sure you have:
- Node.js 18+ — run
node --versionto check - Basic knowledge of HTML, CSS, and JavaScript
- Familiarity with Svelte basics (helpful but not required)
- A code editor (VS Code with the Svelte extension recommended)
Why SvelteKit 2?
What is SvelteKit?
SvelteKit is the official framework for building web applications with Svelte. It combines the best features of modern frameworks:
| Feature | Description |
|---|---|
| File-based routing | Folder structure automatically defines routes |
| SSR + CSR | Hybrid server and client rendering |
| Form Actions | Server-side form processing without client JS |
| Deployment adapters | Deploy anywhere (Vercel, Node, Cloudflare) |
| Exceptional performance | Smaller bundles compared to React/Next.js |
Why Svelte Instead of React?
Svelte works as a compiler rather than a runtime library. This means:
- 40-70% smaller bundles compared to React
- No Virtual DOM — direct updates to the real DOM
- Less code — simpler, cleaner syntax
- Built-in reactivity — no
useStateoruseEffect
Step 1: Create the Project
Start by creating a new SvelteKit project:
npx sv create notes-appWhen prompted, select:
┌ Welcome to the Svelte CLI!
│
◇ Which template would you like?
│ SvelteKit minimal
│
◇ Add type checking with TypeScript?
│ Yes, using TypeScript syntax
│
◇ What would you like to add to your project?
│ prettier, eslint
│
◇ Which package manager do you want to install dependencies with?
│ npm
│
└ You're all set!
Enter the directory and start the dev server:
cd notes-app
npm run devOpen http://localhost:5173 — you'll see the default welcome page.
Step 2: Understand the Project Structure
notes-app/
├── src/
│ ├── lib/ # Shared libraries and components
│ │ └── index.ts
│ ├── routes/ # App pages (file-based routing)
│ │ └── +page.svelte
│ ├── app.html # Main HTML template
│ └── app.d.ts # TypeScript types
├── static/ # Static files (images, fonts)
├── svelte.config.js # SvelteKit configuration
├── tsconfig.json
├── vite.config.ts
└── package.json
Special Files in SvelteKit
| File | Purpose |
|---|---|
+page.svelte | Page UI component |
+page.server.ts | Server-side logic (data loading, Form Actions) |
+page.ts | Universal data loading (server + client) |
+layout.svelte | Shared layout wrapping pages |
+layout.server.ts | Shared layout data |
+server.ts | API route (GET, POST, PUT, DELETE) |
+error.svelte | Custom error page |
Step 3: Set Up the Database
Install better-sqlite3 for data management:
npm install better-sqlite3
npm install -D @types/better-sqlite3Create the database setup file:
// src/lib/server/db.ts
import Database from 'better-sqlite3';
import { dev } from '$app/environment';
import path from 'path';
const dbPath = dev ? 'notes.db' : path.join(process.cwd(), 'notes.db');
const db = new Database(dbPath);
// Enable WAL mode for better performance
db.pragma('journal_mode = WAL');
// Create the notes table
db.exec(`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
color TEXT NOT NULL DEFAULT '#ffffff',
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
export default db;Create the types file:
// src/lib/types.ts
export interface Note {
id: number;
title: string;
content: string;
color: string;
pinned: number;
created_at: string;
updated_at: string;
}Step 4: Build the Main Layout
Replace the main layout content:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
<div class="app">
<header>
<nav>
<a href="/" class="logo">📝 My Notes</a>
<div class="nav-links">
<a href="/">Home</a>
<a href="/notes">Notes</a>
<a href="/about">About</a>
</div>
</nav>
</header>
<main>
{@render children()}
</main>
<footer>
<p>Built with SvelteKit 2 © {new Date().getFullYear()}</p>
</footer>
</div>
<style>
:global(body) {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: #1a1a2e;
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
nav {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: bold;
color: white;
text-decoration: none;
}
.nav-links {
display: flex;
gap: 1.5rem;
}
.nav-links a {
color: #a8a8b3;
text-decoration: none;
transition: color 0.2s;
}
.nav-links a:hover {
color: white;
}
main {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
width: 100%;
box-sizing: border-box;
}
footer {
background: #1a1a2e;
color: #a8a8b3;
text-align: center;
padding: 1rem;
margin-top: auto;
}
</style>Step 5: The Home Page
<!-- src/routes/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
<title>My Notes — SvelteKit App</title>
</svelte:head>
<section class="hero">
<h1>Welcome to Your Notes App</h1>
<p>A full-stack app built with SvelteKit 2, TypeScript, and SQLite</p>
<a href="/notes" class="cta-button">Get Started →</a>
</section>
<section class="stats">
<div class="stat-card">
<span class="stat-number">{data.totalNotes}</span>
<span class="stat-label">Notes</span>
</div>
<div class="stat-card">
<span class="stat-number">{data.pinnedNotes}</span>
<span class="stat-label">Pinned</span>
</div>
<div class="stat-card">
<span class="stat-number">{data.recentNotes}</span>
<span class="stat-label">This Week</span>
</div>
</section>
<style>
.hero {
text-align: center;
padding: 4rem 2rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 16px;
color: white;
margin-bottom: 2rem;
}
.hero h1 {
font-size: 2.5rem;
margin-bottom: 1rem;
}
.hero p {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 2rem;
}
.cta-button {
display: inline-block;
padding: 0.8rem 2rem;
background: white;
color: #667eea;
border-radius: 8px;
text-decoration: none;
font-weight: bold;
font-size: 1.1rem;
transition: transform 0.2s;
}
.cta-button:hover {
transform: translateY(-2px);
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.stat-card {
background: white;
padding: 2rem;
border-radius: 12px;
text-align: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.stat-number {
display: block;
font-size: 3rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
display: block;
color: #666;
margin-top: 0.5rem;
}
</style>Create the data loading file:
// src/routes/+page.server.ts
import db from '$lib/server/db';
export function load() {
const totalNotes = db.prepare('SELECT COUNT(*) as count FROM notes').get() as { count: number };
const pinnedNotes = db.prepare('SELECT COUNT(*) as count FROM notes WHERE pinned = 1').get() as { count: number };
const recentNotes = db.prepare(
"SELECT COUNT(*) as count FROM notes WHERE created_at >= datetime('now', '-7 days')"
).get() as { count: number };
return {
totalNotes: totalNotes.count,
pinnedNotes: pinnedNotes.count,
recentNotes: recentNotes.count
};
}Step 6: The Notes List Page
This is the core page — displaying and adding notes using Form Actions:
// src/routes/notes/+page.server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export function load() {
const notes = db.prepare(
'SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC'
).all() as Note[];
return { notes };
}
export const actions: Actions = {
create: async ({ request }) => {
const formData = await request.formData();
const title = formData.get('title')?.toString().trim();
const content = formData.get('content')?.toString().trim() ?? '';
const color = formData.get('color')?.toString() ?? '#ffffff';
if (!title) {
return fail(400, { error: 'Title is required', title, content, color });
}
if (title.length > 200) {
return fail(400, { error: 'Title is too long (max 200 characters)', title, content, color });
}
db.prepare(
'INSERT INTO notes (title, content, color) VALUES (?, ?, ?)'
).run(title, content, color);
return { success: true };
},
delete: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id');
if (!id) {
return fail(400, { error: 'Note ID is required' });
}
db.prepare('DELETE FROM notes WHERE id = ?').run(id);
return { success: true };
},
togglePin: async ({ request }) => {
const formData = await request.formData();
const id = formData.get('id');
if (!id) {
return fail(400, { error: 'Note ID is required' });
}
db.prepare(
'UPDATE notes SET pinned = CASE WHEN pinned = 1 THEN 0 ELSE 1 END, updated_at = datetime(\'now\') WHERE id = ?'
).run(id);
return { success: true };
}
};<!-- src/routes/notes/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let showForm = $state(false);
const colors = [
{ value: '#ffffff', label: 'White' },
{ value: '#fff3cd', label: 'Yellow' },
{ value: '#d1ecf1', label: 'Blue' },
{ value: '#d4edda', label: 'Green' },
{ value: '#f8d7da', label: 'Pink' },
{ value: '#e2d9f3', label: 'Purple' }
];
</script>
<svelte:head>
<title>Notes — SvelteKit App</title>
</svelte:head>
<div class="notes-page">
<div class="page-header">
<h1>My Notes</h1>
<button class="add-btn" onclick={() => showForm = !showForm}>
{showForm ? '✕ Cancel' : '+ New Note'}
</button>
</div>
{#if showForm}
<form method="POST" action="?/create" use:enhance class="note-form">
{#if form?.error}
<div class="error">{form.error}</div>
{/if}
<input
type="text"
name="title"
placeholder="Note title..."
value={form?.title ?? ''}
required
maxlength="200"
/>
<textarea
name="content"
placeholder="Note content..."
rows="4"
>{form?.content ?? ''}</textarea>
<div class="color-picker">
<span>Color:</span>
{#each colors as color}
<label class="color-option">
<input
type="radio"
name="color"
value={color.value}
checked={color.value === (form?.color ?? '#ffffff')}
/>
<span
class="color-swatch"
style="background: {color.value}"
title={color.label}
></span>
</label>
{/each}
</div>
<button type="submit" class="submit-btn">Save Note</button>
</form>
{/if}
{#if data.notes.length === 0}
<div class="empty-state">
<p>No notes yet. Add your first note!</p>
</div>
{:else}
<div class="notes-grid">
{#each data.notes as note (note.id)}
<div class="note-card" style="background: {note.color}">
<div class="note-header">
<h3>{note.title}</h3>
<div class="note-actions">
<form method="POST" action="?/togglePin" use:enhance>
<input type="hidden" name="id" value={note.id} />
<button type="submit" class="icon-btn" title={note.pinned ? 'Unpin' : 'Pin'}>
{note.pinned ? '📌' : '📍'}
</button>
</form>
<form method="POST" action="?/delete" use:enhance>
<input type="hidden" name="id" value={note.id} />
<button type="submit" class="icon-btn delete" title="Delete">🗑️</button>
</form>
</div>
</div>
{#if note.content}
<p class="note-content">{note.content}</p>
{/if}
<time class="note-date">
{new Date(note.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</time>
</div>
{/each}
</div>
{/if}
</div>
<style>
.notes-page {
max-width: 900px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.page-header h1 {
font-size: 2rem;
color: #1a1a2e;
}
.add-btn {
padding: 0.6rem 1.2rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: background 0.2s;
}
.add-btn:hover {
background: #5a6fd6;
}
.note-form {
background: white;
padding: 1.5rem;
border-radius: 12px;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 1rem;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 0.8rem;
border-radius: 6px;
font-size: 0.9rem;
}
input[type="text"],
textarea {
padding: 0.8rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
font-family: inherit;
}
input[type="text"]:focus,
textarea:focus {
outline: none;
border-color: #667eea;
}
.color-picker {
display: flex;
align-items: center;
gap: 0.8rem;
}
.color-option {
cursor: pointer;
}
.color-option input {
display: none;
}
.color-swatch {
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #ddd;
transition: transform 0.2s;
}
.color-option input:checked + .color-swatch {
border-color: #667eea;
transform: scale(1.2);
}
.submit-btn {
padding: 0.8rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
font-weight: bold;
}
.submit-btn:hover {
background: #5a6fd6;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: #999;
font-size: 1.1rem;
}
.notes-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
.note-card {
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: transform 0.2s, box-shadow 0.2s;
}
.note-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
}
.note-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.8rem;
}
.note-header h3 {
margin: 0;
font-size: 1.1rem;
color: #1a1a2e;
flex: 1;
}
.note-actions {
display: flex;
gap: 0.3rem;
}
.icon-btn {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.2rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.icon-btn:hover {
opacity: 1;
}
.note-content {
color: #555;
font-size: 0.95rem;
line-height: 1.5;
margin-bottom: 1rem;
}
.note-date {
font-size: 0.8rem;
color: #999;
}
</style>Step 7: The Note Edit Page
Create the edit page route with a dynamic parameter:
// src/routes/notes/[id]/+page.server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(params.id) as Note | undefined;
if (!note) {
throw error(404, 'Note not found');
}
return { note };
};
export const actions: Actions = {
update: async ({ request, params }) => {
const formData = await request.formData();
const title = formData.get('title')?.toString().trim();
const content = formData.get('content')?.toString().trim() ?? '';
const color = formData.get('color')?.toString() ?? '#ffffff';
if (!title) {
return fail(400, { error: 'Title is required' });
}
db.prepare(
"UPDATE notes SET title = ?, content = ?, color = ?, updated_at = datetime('now') WHERE id = ?"
).run(title, content, color, params.id);
redirect(303, '/notes');
}
};<!-- src/routes/notes/[id]/+page.svelte -->
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const colors = [
{ value: '#ffffff', label: 'White' },
{ value: '#fff3cd', label: 'Yellow' },
{ value: '#d1ecf1', label: 'Blue' },
{ value: '#d4edda', label: 'Green' },
{ value: '#f8d7da', label: 'Pink' },
{ value: '#e2d9f3', label: 'Purple' }
];
</script>
<svelte:head>
<title>Edit: {data.note.title}</title>
</svelte:head>
<div class="edit-page">
<h1>Edit Note</h1>
<form method="POST" action="?/update" use:enhance class="edit-form">
{#if form?.error}
<div class="error">{form.error}</div>
{/if}
<label>
Title
<input type="text" name="title" value={data.note.title} required maxlength="200" />
</label>
<label>
Content
<textarea name="content" rows="8">{data.note.content}</textarea>
</label>
<div class="color-picker">
<span>Color:</span>
{#each colors as color}
<label class="color-option">
<input
type="radio"
name="color"
value={color.value}
checked={color.value === data.note.color}
/>
<span class="color-swatch" style="background: {color.value}" title={color.label}></span>
</label>
{/each}
</div>
<div class="form-actions">
<a href="/notes" class="cancel-btn">Cancel</a>
<button type="submit" class="save-btn">Save Changes</button>
</div>
</form>
</div>
<style>
.edit-page {
max-width: 700px;
margin: 0 auto;
}
.edit-page h1 {
color: #1a1a2e;
margin-bottom: 1.5rem;
}
.edit-form {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
display: flex;
flex-direction: column;
gap: 1.2rem;
}
label {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-weight: 500;
color: #333;
}
input[type="text"],
textarea {
padding: 0.8rem;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
font-family: inherit;
}
input[type="text"]:focus,
textarea:focus {
outline: none;
border-color: #667eea;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 0.8rem;
border-radius: 6px;
}
.color-picker {
display: flex;
align-items: center;
gap: 0.8rem;
}
.color-option input { display: none; }
.color-swatch {
display: inline-block;
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid #ddd;
cursor: pointer;
transition: transform 0.2s;
}
.color-option input:checked + .color-swatch {
border-color: #667eea;
transform: scale(1.2);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 1rem;
margin-top: 1rem;
}
.cancel-btn {
padding: 0.8rem 1.5rem;
color: #666;
text-decoration: none;
border-radius: 8px;
border: 2px solid #e0e0e0;
}
.save-btn {
padding: 0.8rem 1.5rem;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: bold;
font-size: 1rem;
}
.save-btn:hover {
background: #5a6fd6;
}
</style>Step 8: API Routes
Create RESTful API routes for programmatic access to notes:
// src/routes/api/notes/+server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { json } from '@sveltejs/kit';
export function GET({ url }) {
const search = url.searchParams.get('search');
const pinned = url.searchParams.get('pinned');
let query = 'SELECT * FROM notes';
const conditions: string[] = [];
const params: unknown[] = [];
if (search) {
conditions.push('(title LIKE ? OR content LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (pinned === 'true') {
conditions.push('pinned = 1');
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY pinned DESC, updated_at DESC';
const notes = db.prepare(query).all(...params) as Note[];
return json(notes);
}
export async function POST({ request }) {
const { title, content, color } = await request.json();
if (!title?.trim()) {
return json({ error: 'Title is required' }, { status: 400 });
}
const result = db.prepare(
'INSERT INTO notes (title, content, color) VALUES (?, ?, ?)'
).run(title.trim(), content?.trim() ?? '', color ?? '#ffffff');
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(result.lastInsertRowid) as Note;
return json(note, { status: 201 });
}// src/routes/api/notes/[id]/+server.ts
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { json, error } from '@sveltejs/kit';
export function GET({ params }) {
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(params.id) as Note | undefined;
if (!note) {
throw error(404, 'Note not found');
}
return json(note);
}
export async function PUT({ params, request }) {
const { title, content, color } = await request.json();
if (!title?.trim()) {
return json({ error: 'Title is required' }, { status: 400 });
}
const existing = db.prepare('SELECT id FROM notes WHERE id = ?').get(params.id);
if (!existing) {
throw error(404, 'Note not found');
}
db.prepare(
"UPDATE notes SET title = ?, content = ?, color = ?, updated_at = datetime('now') WHERE id = ?"
).run(title.trim(), content?.trim() ?? '', color ?? '#ffffff', params.id);
const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(params.id) as Note;
return json(note);
}
export function DELETE({ params }) {
const existing = db.prepare('SELECT id FROM notes WHERE id = ?').get(params.id);
if (!existing) {
throw error(404, 'Note not found');
}
db.prepare('DELETE FROM notes WHERE id = ?').run(params.id);
return json({ success: true });
}Step 9: Custom Error Page
<!-- src/routes/+error.svelte -->
<script lang="ts">
import { page } from '$app/state';
</script>
<div class="error-page">
<h1>{page.status}</h1>
<p>{page.error?.message ?? 'An unexpected error occurred'}</p>
<a href="/">Go Home</a>
</div>
<style>
.error-page {
text-align: center;
padding: 4rem 2rem;
}
.error-page h1 {
font-size: 6rem;
color: #667eea;
margin-bottom: 0.5rem;
}
.error-page p {
font-size: 1.3rem;
color: #666;
margin-bottom: 2rem;
}
.error-page a {
padding: 0.8rem 2rem;
background: #667eea;
color: white;
border-radius: 8px;
text-decoration: none;
}
</style>Step 10: Hooks and Middleware
SvelteKit provides a powerful hooks system for request handling:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// Measure response time
const start = Date.now();
const response = await resolve(event);
const duration = Date.now() - start;
console.log(`${event.request.method} ${event.url.pathname} — ${duration}ms`);
// Add security headers
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
return response;
};Step 11: Progressive Loading with Streaming
An advanced SvelteKit feature — you can stream slow data progressively:
// src/routes/notes/+page.server.ts (enhanced version)
import db from '$lib/server/db';
import type { Note } from '$lib/types';
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
export function load() {
// Fast data loads immediately
const notes = db.prepare(
'SELECT * FROM notes ORDER BY pinned DESC, updated_at DESC'
).all() as Note[];
// Slow data streams later (e.g., complex statistics)
const stats = new Promise<{ wordCount: number }>((resolve) => {
const result = db.prepare(
"SELECT SUM(LENGTH(content) - LENGTH(REPLACE(content, ' ', '')) + 1) as wordCount FROM notes"
).get() as { wordCount: number | null };
resolve({ wordCount: result.wordCount ?? 0 });
});
return {
notes,
streamed: { stats }
};
}
// ... actions remain the sameUse the streamed data in the UI:
{#await data.streamed.stats}
<p class="loading">Calculating statistics...</p>
{:then stats}
<p class="word-count">Total words: {stats.wordCount}</p>
{/await}Step 12: Deployment Setup
Deploy to Node.js
npm install @sveltejs/adapter-node// svelte.config.js
import adapter from '@sveltejs/adapter-node';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
out: 'build',
precompress: true
})
}
};
export default config;# Build and deploy
npm run build
node buildDeploy to Vercel
# No adapter change needed — the default adapter works with Vercel
npx vercelDeploy with Docker
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
RUN npm ci --omit=dev
EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "build"]Testing Your App
Run Locally
npm run devTest the Routes
- Home page:
http://localhost:5173— displays statistics - Notes:
http://localhost:5173/notes— add, edit, and delete - API:
curl http://localhost:5173/api/notes— returns JSON
Test Form Actions
Form Actions work without JavaScript! Try disabling JS in your browser — forms will continue working because they're processed on the server.
Troubleshooting
Error: Cannot find module '$lib/server/db'
Make sure you created the src/lib/server/ directory and that the db.ts file exists inside it.
Error: better-sqlite3 not working
npm rebuild better-sqlite3404 on dynamic routes
Make sure the folder name contains square brackets: [id] not id.
Data not updating on the page
Check that use:enhance is added to the <form> element — without it, the entire page will reload.
What You Learned
In this tutorial, you learned how to:
- Create a SvelteKit 2 project from scratch
- File-based routing with dynamic parameters
- Form Actions for secure server-side form processing
- Data loading using
loadfunctions - API routes with GET, POST, PUT, and DELETE
- Data streaming for progressive loading
- Hooks for request handling and middleware
- Deployment to Node.js, Vercel, and Docker
Next Steps
- Authentication: Add login using Lucia Auth
- Production database: Replace SQLite with PostgreSQL via Drizzle ORM
- Testing: Add E2E tests using Playwright
- PWA: Convert the app to a Progressive Web App
- Official docs: svelte.dev/docs/kit
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack Web App with Deno 2 and Fresh Framework
Learn how to build a full-stack task manager app with Deno 2 and Fresh framework. This hands-on tutorial covers islands architecture, server-side rendering, API routes, and Deno KV for data persistence.

Building a Full-Stack Web App with SolidStart: A Complete Hands-On Guide
Learn how to build a full-stack task manager app with SolidStart and SolidJS. This tutorial covers file-based routing, server functions, createAsync data loading, actions, mutations, and deployment.

Building a Multi-Tenant App with Next.js
Learn how to build a full-stack multi-tenant application using Next.js, Vercel, and other modern technologies.