MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

SvelteKit: Full-Stack Svelte Framework

Build full-stack applications with SvelteKit: server-side rendering, form actions, and edge deployment.

SvelteSvelteKitFull StackWeb Development

By MinhVo

Introduction

SvelteKit is the official application framework for Svelte, providing server-side rendering, file-based routing, form actions, and seamless deployment to any platform. Unlike React-based frameworks that ship a runtime to the browser, Svelte compiles components to vanilla JavaScript at build time, resulting in smaller bundles and faster runtime performance.

SvelteKit combines this compilation advantage with a full-stack architecture inspired by Remixβ€”using load functions for data fetching and form actions for mutations, all with progressive enhancement built in. This means your application works without JavaScript, then SvelteKit hydrates it into a fully interactive single-page app.

This guide covers every aspect of building production SvelteKit applications: routing architecture, data loading patterns, form handling, authentication, deployment strategies, and performance optimization techniques that make SvelteKit one of the most productive frameworks available in 2024.

Svelte ecosystem

Why SvelteKit Stands Out

The Compilation Advantage

Svelte's fundamental innovation is moving the framework work from runtime to compile time. Where React and Vue ship a virtual DOM diffing engine to the browser, Svelte analyzes your components during build and generates imperative code that directly updates the DOM. This eliminates the runtime overhead of virtual DOM reconciliation.

The practical impact is significant. A SvelteKit application typically ships 40–60% less JavaScript than an equivalent React application. For users on mobile devices with constrained bandwidth and CPU, this difference translates directly into faster load times and better interactivity scores.

Framework Comparison

FeatureSvelteKitNext.jsRemixNuxt
Component frameworkSvelteReactReactVue
Bundle sizeMinimal (no runtime)~40KB React~40KB React~30KB Vue
RoutingFile-basedFile-basedFile-basedFile-based
Data loadingload functionsRSC / SSRloadersasyncData
Form handlingForm actionsServer ActionsactionsForm actions
SSRBuilt-inBuilt-inBuilt-inBuilt-in
Adapter systemYes (Node, Edge, Static)LimitedYesYes
Learning curveLowMediumMediumLow

File-Based Routing

SvelteKit uses the filesystem to define routes. Each directory under src/routes maps to a URL segment, and special files with the + prefix define behavior for that route.

Route Structure

src/routes/
β”œβ”€β”€ +layout.svelte          # Root layout (wraps all pages)
β”œβ”€β”€ +layout.server.ts       # Root layout data (runs on server)
β”œβ”€β”€ +page.svelte            # Home page /
β”œβ”€β”€ +page.server.ts         # Home page server logic
β”œβ”€β”€ +error.svelte           # Error boundary
β”œβ”€β”€ about/
β”‚   └── +page.svelte        # /about
β”œβ”€β”€ blog/
β”‚   β”œβ”€β”€ +page.svelte        # /blog (list)
β”‚   β”œβ”€β”€ +page.server.ts     # Blog list data
β”‚   └── [slug]/
β”‚       β”œβ”€β”€ +page.svelte    # /blog/:slug (detail)
β”‚       └── +page.server.ts # Blog detail data
β”œβ”€β”€ api/
β”‚   └── users/
β”‚       └── +server.ts      # REST API: /api/users
β”œβ”€β”€ (marketing)/
β”‚   β”œβ”€β”€ +layout.svelte      # Marketing layout
β”‚   β”œβ”€β”€ pricing/
β”‚   β”‚   └── +page.svelte    # /pricing
β”‚   └── features/
β”‚       └── +page.svelte    # /features
└── [...catchall]/
    └── +page.svelte        # Catch-all 404

Dynamic Routes and Parameters

// src/routes/blog/[slug]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
 
export const load: PageServerLoad = async ({ params, locals }) => {
  const post = await db.posts.findUnique({
    where: { slug: params.slug },
    include: { author: true, tags: true },
  });
 
  if (!post) {
    throw error(404, { message: 'Post not found' });
  }
 
  // Increment view count (fire and forget)
  db.posts.update({
    where: { id: post.id },
    data: { views: { increment: 1 } },
  });
 
  return {
    post: {
      ...post,
      views: post.views + 1,
    },
  };
};

Route Groups

Route groups let you organize routes without affecting the URL structure. Wrap a directory name in parentheses to create a group.

src/routes/
β”œβ”€β”€ (auth)/
β”‚   β”œβ”€β”€ +layout.svelte      # Auth layout (centered form)
β”‚   β”œβ”€β”€ login/
β”‚   β”‚   └── +page.svelte    # /login
β”‚   └── register/
β”‚       └── +page.svelte    # /register
β”œβ”€β”€ (app)/
β”‚   β”œβ”€β”€ +layout.svelte      # App layout (sidebar + header)
β”‚   β”œβ”€β”€ +layout.server.ts   # Load user session
β”‚   β”œβ”€β”€ dashboard/
β”‚   β”‚   └── +page.svelte    # /dashboard
β”‚   └── settings/
β”‚       └── +page.svelte    # /settings

Data Loading

Page Load Functions

Load functions fetch data before the page renders. They run on the server during SSR and on the client during client-side navigation.

// src/routes/dashboard/+page.server.ts
import type { PageServerLoad } from './$types';
 
export const load: PageServerLoad = async ({ locals, fetch }) => {
  // locals is populated by hooks.server.ts
  if (!locals.user) {
    throw redirect(302, '/login');
  }
 
  const [stats, recentActivity, notifications] = await Promise.all([
    db.stats.getForUser(locals.user.id),
    db.activity.findMany({
      where: { userId: locals.user.id },
      orderBy: { createdAt: 'desc' },
      take: 10,
    }),
    fetch('/api/notifications').then(r => r.json()),
  ]);
 
  return {
    stats,
    recentActivity,
    notifications,
  };
};

Layout Load Functions

Layout load functions provide data shared across all pages in a section.

// src/routes/(app)/+layout.server.ts
import type { LayoutServerLoad } from './$types';
 
export const load: LayoutServerLoad = async ({ locals }) => {
  return {
    user: locals.user,
    sidebarCollapsed: locals.preferences?.sidebarCollapsed ?? false,
  };
};

Streaming with Promises

For slow data sources, return promises from load functions. SvelteKit streams the data as it resolves.

export const load: PageServerLoad = async ({ params }) => {
  return {
    // This data loads immediately
    product: await db.products.findUnique({ where: { id: params.id } }),
 
    // This streams in when ready (slower query)
    reviews: db.reviews.findMany({
      where: { productId: params.id },
      orderBy: { createdAt: 'desc' },
    }),
 
    // External API call streams in
    recommendations: fetch(`/api/recommendations/${params.id}`).then(r => r.json()),
  };
};

Web development workflow

Form Actions

Form actions handle form submissions on the server with progressive enhancement built in. They work without JavaScript, then SvelteKit enhances them with client-side behavior.

Defining Form Actions

// src/routes/todos/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions } from './$types';
 
export const actions: Actions = {
  create: async ({ request, locals }) => {
    const formData = await request.formData();
    const text = formData.get('text') as string;
    const priority = formData.get('priority') as string;
 
    // Validation
    if (!text || text.trim().length < 1) {
      return fail(400, {
        text,
        error: 'Todo text is required',
        type: 'create',
      });
    }
 
    if (text.length > 500) {
      return fail(400, {
        text,
        error: 'Todo must be under 500 characters',
        type: 'create',
      });
    }
 
    await db.todos.create({
      data: {
        text: text.trim(),
        priority: priority || 'normal',
        userId: locals.user.id,
      },
    });
 
    return { success: true, type: 'create' };
  },
 
  toggle: async ({ request }) => {
    const formData = await request.formData();
    const id = formData.get('id') as string;
 
    const todo = await db.todos.findUnique({ where: { id } });
    if (!todo) return fail(404, { error: 'Todo not found' });
 
    await db.todos.update({
      where: { id },
      data: { completed: !todo.completed },
    });
  },
 
  delete: async ({ request }) => {
    const formData = await request.formData();
    const id = formData.get('id') as string;
    await db.todos.delete({ where: { id } });
  },
};

Form Component with Progressive Enhancement

<!-- src/routes/todos/+page.svelte -->
<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionData, PageData } from './$types';
 
  export let data: PageData;
  export let form: ActionData;
 
  let creating = false;
</script>
 
<form
  method="POST"
  action="?/create"
  use:enhance={() => {
    creating = true;
    return async ({ update }) => {
      await update();
      creating = false;
    };
  }}
>
  <input
    name="text"
    value={form?.text ?? ''}
    placeholder="What needs to be done?"
    aria-invalid={form?.error ? 'true' : undefined}
  />
  <select name="priority">
    <option value="low">Low</option>
    <option value="normal" selected>Normal</option>
    <option value="high">High</option>
  </select>
  <button type="submit" disabled={creating}>
    {creating ? 'Adding...' : 'Add Todo'}
  </button>
  {#if form?.error}
    <p class="error" role="alert">{form.error}</p>
  {/if}
</form>
 
<ul>
  {#each data.todos as todo (todo.id)}
    <li class:completed={todo.completed}>
      <form method="POST" action="?/toggle" use:enhance style="display:inline">
        <input type="hidden" name="id" value={todo.id} />
        <button type="submit" class="toggle">
          {todo.completed ? 'βœ“' : 'β—‹'}
        </button>
      </form>
      <span>{todo.text}</span>
      <form method="POST" action="?/delete" use:enhance style="display:inline">
        <input type="hidden" name="id" value={todo.id} />
        <button type="submit" class="delete">Γ—</button>
      </form>
    </li>
  {/each}
</ul>

Server Hooks

Hooks intercept requests and responses at the server level.

// src/hooks.server.ts
import type { Handle, HandleFetch } from '@sveltejs/kit';
import { verifyToken } from '$lib/server/auth';
 
export const handle: Handle = async ({ event, resolve }) => {
  // Extract session from cookie
  const token = event.cookies.get('session');
 
  if (token) {
    try {
      const user = await verifyToken(token);
      event.locals.user = user;
    } catch {
      // Invalid token β€” clear it
      event.cookies.delete('session', { path: '/' });
    }
  }
 
  // Set CSP headers for security
  const response = await resolve(event);
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; script-src 'self' 'unsafe-inline'"
  );
 
  return response;
};
 
export const handleFetch: HandleFetch = async ({ request, fetch }) => {
  // Add auth headers to internal API calls
  if (request.url.startsWith('/api/')) {
    // Custom logic here
  }
  return fetch(request);
};

Authentication Pattern

// src/lib/server/auth.ts
import { redirect } from '@sveltejs/kit';
import type { ServerLoad } from '@sveltejs/kit';
 
// Guard function for protected routes
export function requireAuth(locals: App.Locals): NonNullable<App.Locals['user']> {
  if (!locals.user) {
    throw redirect(302, '/login');
  }
  return locals.user;
}
 
// Usage in load functions
export const load: ServerLoad = async ({ locals }) => {
  const user = requireAuth(locals);
  return { user };
};

Deployment architecture

Adapters and Deployment

SvelteKit's adapter system builds your application for any target platform.

Adapter Configuration

// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
 
export default {
  preprocess: vitePreprocess(),
  kit: {
    adapter: adapter(),
    alias: {
      '$components': 'src/lib/components',
      '$utils': 'src/lib/utils',
    },
  },
};

Platform-Specific Adapters

# Node.js (Express, Fastify, etc.)
npm i -D @sveltejs/adapter-node
 
# Vercel (serverless + edge)
npm i -D @sveltejs/adapter-vercel
 
# Cloudflare Pages
npm i -D @sveltejs/adapter-cloudflare
 
# Static site generation
npm i -D @sveltejs/adapter-static
 
# Netlify
npm i -D @sveltejs/adapter-netlify
 
# AWS Lambda
npm i -D @sveltejs/adapter-node

Static Site Generation

// svelte.config.js
import adapter from '@sveltejs/adapter-static';
 
export default {
  kit: {
    adapter: adapter({
      pages: 'build',
      assets: 'build',
      fallback: '404.html',
      precompress: true,
    }),
  },
};
// src/routes/+layout.ts
export const prerender = true; // Prerender all pages

Advanced Patterns

Server-Only Modules

SvelteKit enforces the server/client boundary at build time. Files in $lib/server cannot be imported by client code.

// src/lib/server/db.ts β€” this will NEVER be in the client bundle
import { PrismaClient } from '@prisma/client';
 
const prisma = new PrismaClient();
export default prisma;

Environment Variables

// src/lib/server/env.ts
import { env } from '$env/dynamic/private';
import { PUBLIC_API_URL } from '$env/static/public';
 
// env.DATABASE_URL β€” only available on server
// PUBLIC_API_URL β€” available on both server and client

Error Handling

<!-- src/routes/+error.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
</script>
 
<div class="error-page">
  <h1>{$page.status}</h1>
  <p>{$page.error?.message ?? 'Something went wrong'}</p>
  {#if $page.status === 404}
    <a href="/">Go home</a>
  {/if}
</div>

Component Patterns

<!-- src/lib/components/DataGrid.svelte -->
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
 
  type T = $$Generic;
  export let data: T[];
  export let columns: { key: keyof T; label: string; width?: string }[];
  export let loading = false;
 
  const dispatch = createEventDispatcher<{
    rowClick: T;
    sort: { column: keyof T; direction: 'asc' | 'desc' };
  }>();
</script>
 
<table>
  <thead>
    <tr>
      {#each columns as col}
        <th style:width={col.width}>
          <button on:click={() => dispatch('sort', { column: col.key, direction: 'asc' })}>
            {col.label}
          </button>
        </th>
      {/each}
    </tr>
  </thead>
  <tbody>
    {#if loading}
      <tr><td colspan={columns.length}>Loading...</td></tr>
    {:else}
      {#each data as row, i (i)}
        <tr on:click={() => dispatch('rowClick', row)}>
          {#each columns as col}
            <td>{row[col.key]}</td>
          {/each}
        </tr>
      {/each}
    {/if}
  </tbody>
</table>

Best Practices

  1. Use $types for type safety: Import generated types for load functions, actions, and route params
  2. Keep load functions fast: They run on every navigation β€” avoid expensive queries without streaming
  3. Use form actions over API routes: Better progressive enhancement and simpler mutation handling
  4. Leverage $lib aliases: Import from $lib/components, $lib/utils, $lib/server for clean paths
  5. Use streaming for slow data: Return promises in load functions to render available data immediately
  6. Prefer server-only modules: Place sensitive code in $lib/server to prevent client leakage
  7. Use route groups: Organize layouts without affecting URL structure
  8. Implement proper error boundaries: Add +error.svelte files at each route level

Common Pitfalls

PitfallImpactSolution
Fetching in onMountNo SSR data, slower LCPUse load functions instead
Missing error pagesWhite screen on errorsAdd +error.svelte at each level
Not using progressive enhancementBroken without JSUse form actions with use:enhance
Large load function responsesSlow serializationReturn minimal data
Wrong adapterDeployment failsChoose adapter matching platform
Importing server code in clientBuild errorUse $lib/server convention
Blocking load functionsSlow page transitionsUse streaming for parallel data

Architecture Decision Records

When evaluating architectural choices for your project, documenting your decision-making process through Architecture Decision Records (ADRs) provides invaluable context for future team members and stakeholders. Each ADR captures the context, decision, and consequences of a specific architectural choice.

Creating Effective ADRs

An ADR should include the date of the decision, the status (proposed, accepted, deprecated, or superseded), the context that motivated the decision, the decision itself, and the expected consequences both positive and negative. This structured approach ensures that decisions are traceable and reversible when circumstances change.

# ADR-001: Choose React for Frontend Framework
 
## Status: Accepted
 
## Context
We need a frontend framework that supports component-based architecture,
has a large ecosystem, and provides good TypeScript support.
 
## Decision
We will use React 18+ with TypeScript for all new frontend projects.
 
## Consequences
- Large talent pool available for hiring
- Mature ecosystem with extensive third-party libraries
- Strong TypeScript integration
- Requires additional libraries for routing and state management

Decision Matrix for Technology Selection

Create a weighted decision matrix when comparing multiple options. List your evaluation criteria (performance, learning curve, ecosystem maturity, community support, long-term viability) and assign weights based on your project priorities. Score each option on a scale of 1-5 for each criterion, then calculate weighted totals.

This systematic approach removes emotion from technology decisions and provides a defensible rationale when stakeholders question your choices. Document the matrix alongside your ADR so future teams understand not just what was chosen, but why alternatives were rejected.

Reversibility and Migration Paths

Every architectural decision should include a migration path in case the decision needs to be reversed. Consider the cost of changing course at six months, twelve months, and two years. Decisions with low reversal costs can be made more aggressively, while irreversible decisions warrant extended evaluation periods and proof-of-concept implementations.

For example, choosing a CSS-in-JS library has a relatively low reversal cost since styles can be migrated incrementally component by component. However, choosing a database technology has a high reversal cost due to data migration complexity and potential schema changes throughout the codebase.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

SvelteKit combines Svelte's compile-time advantages with a full-stack framework that embraces web standards. Its small bundle sizes, form actions with progressive enhancement, and flexible adapter system make it a compelling choice for modern web applications. The framework's opinionated defaults reduce decision fatigue while remaining flexible enough for complex applications.

Key takeaways:

  1. Svelte compiles away β€” no runtime shipped to the browser, resulting in smaller bundles and faster performance
  2. Load functions handle server-side data fetching per route with streaming support for slow sources
  3. Form actions provide progressive enhancement out of the box β€” your forms work without JavaScript
  4. Adapter system deploys to any platform from Node.js servers to edge functions to static sites
  5. Type safety is built in with generated $types for every route, load function, and form action