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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

  1. Node.js 18+ — run node --version to check
  2. Basic knowledge of HTML, CSS, and JavaScript
  3. Familiarity with Svelte basics (helpful but not required)
  4. 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:

FeatureDescription
File-based routingFolder structure automatically defines routes
SSR + CSRHybrid server and client rendering
Form ActionsServer-side form processing without client JS
Deployment adaptersDeploy anywhere (Vercel, Node, Cloudflare)
Exceptional performanceSmaller 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 useState or useEffect

Step 1: Create the Project

Start by creating a new SvelteKit project:

npx sv create notes-app

When 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 dev

Open 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

FilePurpose
+page.sveltePage UI component
+page.server.tsServer-side logic (data loading, Form Actions)
+page.tsUniversal data loading (server + client)
+layout.svelteShared layout wrapping pages
+layout.server.tsShared layout data
+server.tsAPI route (GET, POST, PUT, DELETE)
+error.svelteCustom error page

Step 3: Set Up the Database

Install better-sqlite3 for data management:

npm install better-sqlite3
npm install -D @types/better-sqlite3

Create 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 &copy; {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 same

Use 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 build

Deploy to Vercel

# No adapter change needed — the default adapter works with Vercel
npx vercel

Deploy 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 dev

Test the Routes

  1. Home page: http://localhost:5173 — displays statistics
  2. Notes: http://localhost:5173/notes — add, edit, and delete
  3. 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-sqlite3

404 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:

  1. Create a SvelteKit 2 project from scratch
  2. File-based routing with dynamic parameters
  3. Form Actions for secure server-side form processing
  4. Data loading using load functions
  5. API routes with GET, POST, PUT, and DELETE
  6. Data streaming for progressive loading
  7. Hooks for request handling and middleware
  8. 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

Want to read more tutorials? Check out our latest tutorial on htmx and Alpine.js: Build Interactive Web Apps Without Heavy JavaScript Frameworks.

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