SvelteKit完全実践ガイド〜Svelte 5 runes・load関数・form actions・SSR・adapter徹底【2026年版】〜

「Svelte 5 で何が変わったか分からない」「SvelteKit の +page.svelte+page.server.ts の使い分けが曖昧」「form actions と progressive enhancement の実装パターンが知りたい」――この記事は Svelte 5 の runes($state / $derived / $effect / $props)から SvelteKit の load / form actions / hooks / adapter まで、コピペで動くコードだけで一気通貫に解説する完全実践ガイドです。2026年最新版(Svelte 5 + SvelteKit 2.x)に準拠し、TypeScript で書き切ります。Next.js / Remix と比較した「Svelte ならではの強み」も最後に整理します。

  1. SvelteKit とは ― 「コンパイラ駆動」のフルスタックフレームワーク
    1. なぜ今 Svelte 5 + SvelteKit 2 なのか
    2. 動作要件と前提
  2. プロジェクト作成 ― npm create svelte でゼロから
    1. 新規プロジェクトの作成
    2. プロジェクト構造の全体像
    3. package.json の主要スクリプト
  3. Svelte 5 runes ― 言語レベルのリアクティビティ
    1. $state ― 値をリアクティブにする
    2. $state.raw ― ディープにしない高速版
    3. $derived ― 派生値(キャッシュされる)
    4. $derived.by ― 複雑な派生はブロック構文で
    5. $effect ― 副作用(useEffect 相当)
    6. $effect.pre ― DOM 更新「前」に走る
    7. $props ― 親からのプロパティ受け取り
    8. $bindable ― 双方向バインディング可能な props
    9. $inspect ― 開発時のリアクティブな console.log
  4. Snippets と Component 構文
    1. snippet ― 再利用可能なテンプレート片
    2. each / if / await の制御構文
    3. クラス・スタイルの動的バインディング
  5. Stores ― legacy ストアと runes 風ステート
    1. 従来の writable / readable / derived
    2. runes だけで「グローバルストア風」を作る
  6. ファイルベースルーティング ― +page.svelte と仲間たち
    1. ルートファイル一覧
    2. 動的セグメントとパラメータ
    3. シンプルな +page.svelte の例
  7. load 関数 ― データフェッチの正攻法
    1. +page.ts ― ユニバーサル load
    2. +page.server.ts ― サーバー専用 load
    3. load の引数 ― 何が使えるか
    4. depends と invalidate ― 任意タイミングで再 load
    5. SvelteKit 拡張 fetch の利点
  8. layout ― 子ルートを囲むレイアウト
    1. +layout.svelte ― children を受け取る
    2. +layout.server.ts ― 共通サーバーデータ
    3. レイアウトグループ ― (group) で URL に出さないネスト
  9. +error.svelte ― エラー画面
  10. +server.ts ― API エンドポイント
    1. GET / POST / DELETE のシンプル実装
    2. 動的パラメータ付きエンドポイント
  11. form actions ― 「JS なしでも動く」フォーム処理
    1. シンプルな default action
    2. named actions ― 複数の action を持つ
    3. use:enhance ― Progressive Enhancement
    4. 楽観的更新パターン
  12. cookies と locals ― セッション管理の基本
    1. cookies の読み書き
    2. app.d.ts ― locals の型定義
  13. hooks.server.ts ― 認証ガードとリクエスト変換
    1. handle ― リクエスト/レスポンスの中間処理
    2. handleFetch ― SSR 中の fetch を書き換える
    3. handleError ― エラーロギング
    4. セッション管理の最小実装例
  14. $app/stores と $app/navigation
    1. $app/stores ― page / navigating / updated
    2. $app/navigation ― goto / preload
  15. レンダリングモード ― CSR / SSR / SSG の切替
    1. SSR を切る / CSR を切る / プリレンダリング
    2. プリレンダリング対象を動的に決める
  16. svelte.config.js と adapter ― 配信先を選ぶ
    1. adapter-auto(デフォルト)
    2. adapter-vercel
    3. adapter-cloudflare
    4. adapter-node ― セルフホスト
    5. adapter-static ― 完全静的サイト
  17. 環境変数 ― $env/static と $env/dynamic
  18. 実践レシピ集 ― よくあるユースケース
    1. ログイン → セッション発行 → リダイレクト
    2. ファイルアップロード
    3. Server-Sent Events(ストリーミング応答)
    4. パラレル load + Streaming SSR
  19. テスト ― Vitest と Playwright
    1. Vitest によるユニットテスト
    2. Playwright による E2E テスト
  20. パフォーマンス&運用のベストプラクティス
    1. data-sveltekit-* 属性でプリロード制御
    2. setHeaders ― CDN キャッシュ制御
    3. 画像最適化(@sveltejs/enhanced-img)
  21. SvelteKit vs Next.js vs React ― 採用判断
    1. SvelteKit を選ぶ理由
    2. Next.js / React を選ぶ理由
    3. 使い分けの目安
  22. 本記事で学んだことのまとめ
    1. 次に読むべき公式ドキュメント

SvelteKit とは ― 「コンパイラ駆動」のフルスタックフレームワーク

SvelteKit は Svelte をベースにしたフルスタック Web フレームワークで、Vite を上に乗せ、ファイルベースルーティング・SSR・SSG・API ルート・form actions・hooks・session・adapter を一式で提供します。Next.js (React) や Nuxt (Vue) と同じ役割を、Svelte の「ランタイムを極限まで削るコンパイラ思想」で実現するのが特徴です。

なぜ今 Svelte 5 + SvelteKit 2 なのか

  • runes 導入: React の useState 相当が言語機能 $state として組み込まれ、リアクティビティが「魔法」から「明示的なAPI」に変わった
  • バンドルサイズが小さい: 仮想 DOM を持たず、コンパイル時に効率的な DOM 更新コードに変換するため、ハイドレーション JS が React より大幅に軽い
  • 学習コストが低い: HTML / CSS / JS を .svelte 1ファイルに書ける。JSX を学ぶ必要がない
  • SvelteKit が単一の正解: Next.js のように「Pages Router か App Router か」で迷う必要がない

動作要件と前提

本ガイドは以下の環境で動作確認を想定しています。

Node.js 20.x or 22.x (LTS)
npm 10.x / pnpm 9.x / bun 1.x いずれか
Svelte 5.x
SvelteKit 2.x
TypeScript 5.x
Vite 5.x

プロジェクト作成 ― npm create svelte でゼロから

新規プロジェクトの作成

2026年現在、SvelteKit の公式スカフォルドは sv create(または npm create svelte@latest)です。TypeScript・ESLint・Prettier・Vitest・Playwright をワンステップで有効化できます。

# 推奨: 最新の sv (Svelte CLI)
npx sv create my-sveltekit-app

# 旧来のコマンドも有効
npm create svelte@latest my-sveltekit-app

cd my-sveltekit-app
npm install
npm run dev -- --open

対話形式で以下を聞かれます。本ガイドの前提に合わせて以下を選択してください。

? Which Svelte app template?   › SvelteKit minimal
? Add type checking with?      › TypeScript
? Select additional options    › [x] ESLint
                                 [x] Prettier
                                 [x] Playwright
                                 [x] Vitest

プロジェクト構造の全体像

作成直後のディレクトリは以下のようになります。src/routes/ 配下がそのまま URL になるのがファイルベースルーティングの核心です。

my-sveltekit-app/
├── src/
│   ├── app.html              ← HTML テンプレート
│   ├── app.d.ts              ← グローバル型
│   ├── hooks.server.ts       ← サーバーフック(認証など)
│   ├── lib/                  ← $lib エイリアス
│   │   ├── components/
│   │   └── server/           ← サーバー専用
│   └── routes/
│       ├── +layout.svelte
│       ├── +page.svelte
│       └── api/
│           └── +server.ts
├── static/                   ← favicon, robots.txt
├── svelte.config.js
├── vite.config.ts
├── tsconfig.json
└── package.json

package.json の主要スクリプト

{
  "name": "my-sveltekit-app",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "preview": "vite preview",
    "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
    "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
    "test": "vitest",
    "test:e2e": "playwright test",
    "lint": "prettier --check . && eslint .",
    "format": "prettier --write ."
  }
}

Svelte 5 runes ― 言語レベルのリアクティビティ

Svelte 5 の最大の変更点は runes(ルーン)と呼ばれる言語拡張です。$state$derived$effect$props など、$ から始まるシンボルが特別な意味を持ちます。Svelte 3/4 の let count = 0 + $: 自動リアクティブを置き換える、「明示的かつ場所を問わない」新しいリアクティブモデルです。

$state ― 値をリアクティブにする

もっとも基本のルーン。プリミティブにもオブジェクト・配列にも使えます。

<script lang="ts">
  let count = $state(0);
  let user = $state({ name: 'Alice', age: 30 });
  let todos = $state<string[]>([]);

  function increment() {
    count += 1;        // 代入だけでリアクティブ
  }

  function rename() {
    user.name = 'Bob'; // ネストしたプロパティも自動で追跡される
  }

  function addTodo() {
    todos.push(`Todo ${todos.length + 1}`); // push でも反応する
  }
</script>

<button onclick={increment}>count: {count}</button>
<button onclick={rename}>name: {user.name}</button>
<ul>
  {#each todos as todo}
    <li>{todo}</li>
  {/each}
</ul>
<button onclick={addTodo}>add todo</button>

$state.raw ― ディープにしない高速版

巨大な配列やオブジェクトを「丸ごと置き換える」用途では $state.raw が高速です。プロパティ単位の追跡をしません。

<script lang="ts">
  // 数万件のグリッドを丸ごと差し替える用途
  let rows = $state.raw<Row[]>([]);

  async function reload() {
    const res = await fetch('/api/rows');
    rows = await res.json(); // 代入で更新(push などは不可)
  }
</script>

$derived ― 派生値(キャッシュされる)

他のリアクティブ値から計算する値は $derived で書きます。React の useMemo 相当ですが、依存配列は不要・自動追跡です。

<script lang="ts">
  let price = $state(1000);
  let quantity = $state(2);
  let taxRate = $state(0.1);

  const subtotal = $derived(price * quantity);
  const tax = $derived(subtotal * taxRate);
  const total = $derived(subtotal + tax);
</script>

<p>小計: {subtotal} 円 / 税: {tax} 円 / 合計: {total} 円</p>
<input type="number" bind:value={price} />
<input type="number" bind:value={quantity} />

$derived.by ― 複雑な派生はブロック構文で

<script lang="ts">
  let items = $state([{ price: 100, qty: 2 }, { price: 300, qty: 1 }]);

  const summary = $derived.by(() => {
    let total = 0;
    let count = 0;
    for (const it of items) {
      total += it.price * it.qty;
      count += it.qty;
    }
    return { total, count };
  });
</script>

<p>{summary.count} 点 / 合計 {summary.total} 円</p>

$effect ― 副作用(useEffect 相当)

DOM への直接操作・タイマー・外部 API 呼び出しは $effect に書きます。依存は読み取った state から自動推論されるため、配列での依存指定は不要です。

<script lang="ts">
  let count = $state(0);

  // count を読んでいるので、count が変わると自動で再実行
  $effect(() => {
    document.title = `count: ${count}`;

    // クリーンアップ(returnした関数が次回実行前に走る)
    return () => {
      console.log('cleanup');
    };
  });
</script>

<button onclick={() => count++}>{count}</button>

$effect.pre ― DOM 更新「前」に走る

<script lang="ts">
  import { tick } from 'svelte';

  let messages = $state<string[]>([]);
  let listEl: HTMLUListElement;
  let autoscroll = $state(true);

  $effect.pre(() => {
    if (!listEl) return;
    // DOM が更新される直前にスクロール位置を測る
    const atBottom = listEl.scrollTop + listEl.clientHeight >= listEl.scrollHeight - 10;
    autoscroll = atBottom;
  });

  $effect(() => {
    if (autoscroll) {
      tick().then(() => listEl.scrollTo(0, listEl.scrollHeight));
    }
  });
</script>

$props ― 親からのプロパティ受け取り

Svelte 4 の export let foo は廃止され、$props() で分割代入する形に統一されました。デフォルト値・rename・rest が一発で書けます。

<!-- Card.svelte -->
<script lang="ts">
  interface Props {
    title: string;
    description?: string;
    variant?: 'primary' | 'secondary';
    children?: import('svelte').Snippet;
  }

  let {
    title,
    description = '',
    variant = 'primary',
    children,
    ...rest
  }: Props = $props();
</script>

<article class="card {variant}" {...rest}>
  <h3>{title}</h3>
  {#if description}
    <p>{description}</p>
  {/if}
  {@render children?.()}
</article>

$bindable ― 双方向バインディング可能な props

<!-- TextInput.svelte -->
<script lang="ts">
  let { value = $bindable(''), placeholder = '' } = $props();
</script>

<input type="text" bind:value {placeholder} />

<!-- 親から -->
<!-- <TextInput bind:value={name} /> -->

$inspect ― 開発時のリアクティブな console.log

<script lang="ts">
  let user = $state({ name: 'Alice', age: 30 });

  // user が変わるたびに console に出力(本番ビルドでは消える)
  $inspect(user);
  $inspect('age changed:', user.age).with((type, value) => {
    if (type === 'update') console.warn('age:', value);
  });
</script>

Snippets と Component 構文

snippet ― 再利用可能なテンプレート片

Svelte 5 では Slot に代わって {#snippet} / {@render} が標準になりました。「props として関数のように渡せるテンプレート」と理解すると分かりやすいです。

<!-- 利用側 -->
<script lang="ts">
  import List from './List.svelte';
  const items = [{ id: 1, name: 'Apple' }, { id: 2, name: 'Banana' }];
</script>

<List {items}>
  {#snippet row(item)}
    <li>#{item.id} {item.name}</li>
  {/snippet}
</List>
<!-- List.svelte -->
<script lang="ts" generics="T extends { id: number }">
  import type { Snippet } from 'svelte';
  let { items, row }: { items: T[]; row: Snippet<[T]> } = $props();
</script>

<ul>
  {#each items as item (item.id)}
    {@render row(item)}
  {/each}
</ul>

each / if / await の制御構文

<script lang="ts">
  let users = $state([{ id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }]);
  let loading = $state(false);

  async function fetchUser(id: number) {
    const res = await fetch(`/api/users/${id}`);
    if (!res.ok) throw new Error('failed');
    return res.json();
  }

  let promise = $state<Promise<any> | null>(null);
</script>

{#if loading}
  <p>loading...</p>
{:else if users.length === 0}
  <p>ユーザーがいません</p>
{:else}
  <ul>
    {#each users as user, i (user.id)}
      <li>{i + 1}. {user.name}</li>
    {:else}
      <li>empty</li>
    {/each}
  </ul>
{/if}

{#if promise}
  {#await promise}
    <p>fetching...</p>
  {:then data}
    <pre>{JSON.stringify(data, null, 2)}</pre>
  {:catch err}
    <p style="color: red">{err.message}</p>
  {/await}
{/if}

クラス・スタイルの動的バインディング

<script lang="ts">
  let active = $state(false);
  let color = $state('#0070f3');
</script>

<button
  class="btn"
  class:active={active}
  class:disabled={!active}
  style:--accent={color}
  style:opacity={active ? 1 : 0.5}
  onclick={() => (active = !active)}
>
  toggle
</button>

<style>
  .btn { color: var(--accent); }
  .btn.active { font-weight: bold; }
</style>

Stores ― legacy ストアと runes 風ステート

従来の writable / readable / derived

SvelteKit 内では今でも従来の svelte/store ストアが利用できます。$ プレフィックスでテンプレート内のサブスクライブが可能です。

// src/lib/stores/counter.ts
import { writable, derived, readable } from 'svelte/store';

export const count = writable(0);
export const doubled = derived(count, ($c) => $c * 2);
export const now = readable(new Date(), (set) => {
  const id = setInterval(() => set(new Date()), 1000);
  return () => clearInterval(id);
});
<script lang="ts">
  import { count, doubled, now } from '$lib/stores/counter';
</script>

<button onclick={() => $count++}>{$count}</button>
<p>doubled: {$doubled} / now: {$now.toLocaleTimeString()}</p>

runes だけで「グローバルストア風」を作る

Svelte 5 では「.svelte.ts」ファイル内であれば runes が使えるため、ストアそのものを runes だけで書けます。クラスで包むのが定石です。

// src/lib/stores/cart.svelte.ts
class CartStore {
  items = $state<{ id: number; qty: number }[]>([]);
  total = $derived(this.items.reduce((s, it) => s + it.qty, 0));

  add(id: number) {
    const found = this.items.find((it) => it.id === id);
    if (found) found.qty += 1;
    else this.items.push({ id, qty: 1 });
  }

  remove(id: number) {
    this.items = this.items.filter((it) => it.id !== id);
  }
}

export const cart = new CartStore();
<script lang="ts">
  import { cart } from '$lib/stores/cart.svelte';
</script>

<p>カゴ: {cart.total} 点</p>
<button onclick={() => cart.add(1)}>追加</button>

ファイルベースルーティング ― +page.svelte と仲間たち

SvelteKit のルーティングは src/routes/ 配下のディレクトリ構造がそのまま URL になります。ファイル名は + から始まるものが特別な意味を持ちます。

ルートファイル一覧

+page.svelte         ← ページの UI(必須)
+page.ts             ← クライアント/サーバー両用 load
+page.server.ts      ← サーバー専用 load + form actions
+layout.svelte       ← 子ルートを囲むレイアウト
+layout.ts
+layout.server.ts
+error.svelte        ← エラー画面
+server.ts           ← API エンドポイント (GET/POST/...)

動的セグメントとパラメータ

角括弧 [param] で動的セグメントを定義します。複数指定・rest・オプショナルもサポートされます。

src/routes/
  blog/
    [slug]/             ← /blog/foo
      +page.svelte
      +page.ts
  shop/
    [category]/[id]/    ← /shop/books/12
      +page.svelte
  files/
    [...path]/          ← /files/a/b/c (rest)
      +page.svelte
  i18n/
    [[lang]]/           ← /i18n または /i18n/ja (optional)
      +page.svelte

シンプルな +page.svelte の例

<!-- src/routes/+page.svelte -->
<script lang="ts">
  import { page } from '$app/stores';

  let name = $state('');
</script>

<svelte:head>
  <title>Home</title>
  <meta name="description" content="SvelteKit demo home" />
</svelte:head>

<h1>Welcome to SvelteKit</h1>
<p>path: {$page.url.pathname}</p>

<input bind:value={name} placeholder="お名前" />
<p>こんにちは、{name || 'ゲスト'}さん</p>

load 関数 ― データフェッチの正攻法

SvelteKit のデータフェッチは load 関数で行います。+page.ts はユニバーサル(サーバーでもブラウザでも動く)、+page.server.ts はサーバー専用です。秘匿情報・DB アクセスは必ず .server.ts 側に書きます。

+page.ts ― ユニバーサル load

// src/routes/blog/[slug]/+page.ts
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ params, fetch }) => {
  const res = await fetch(`/api/posts/${params.slug}`);
  if (!res.ok) {
    throw error(res.status, 'Post not found');
  }
  const post: { title: string; body: string } = await res.json();
  return { post };
};
<!-- src/routes/blog/[slug]/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  let { data }: { data: PageData } = $props();
</script>

<article>
  <h1>{data.post.title}</h1>
  <div>{@html data.post.body}</div>
</article>

+page.server.ts ― サーバー専用 load

DB アクセス・秘匿 API キーが必要な処理はこちら。返り値は devalue でシリアライズ可能な値である必要があります(Date / Map / Set / BigInt OK)。

// src/routes/admin/users/+page.server.ts
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    throw redirect(302, '/login');
  }
  const users = await db.user.findMany({
    select: { id: true, email: true, createdAt: true }
  });
  return { users };
};

load の引数 ― 何が使えるか

// load に渡される event の主要メンバ
type LoadEvent = {
  params: Record<string, string>;     // 動的セグメント
  url: URL;                            // 現在 URL
  route: { id: string };
  fetch: typeof fetch;                 // SvelteKit 拡張版
  setHeaders: (h: Record<string, string>) => void;
  parent: () => Promise<any>;          // 親 layout の load 結果
  depends: (...deps: string[]) => void;
  untrack: <T>(fn: () => T) => T;
  cookies?: Cookies;                   // server load のみ
  locals?: App.Locals;                 // server load のみ
};

depends と invalidate ― 任意タイミングで再 load

// +page.ts
export const load: PageLoad = async ({ fetch, depends }) => {
  depends('app:posts'); // 任意のキー
  const posts = await fetch('/api/posts').then((r) => r.json());
  return { posts };
};
<script lang="ts">
  import { invalidate, invalidateAll } from '$app/navigation';

  async function refresh() {
    await invalidate('app:posts'); // depends したキーをトリガ
  }
  async function refreshAll() {
    await invalidateAll();
  }
</script>

<button onclick={refresh}>再読み込み</button>

SvelteKit 拡張 fetch の利点

  • 同オリジン API は SSR 中に内部呼び出しになり、HTTP を経由しない
  • cookie・credentials が自動転送される
  • SSR 中の fetch 結果が HTML にインライン化され、ハイドレーション直後の再リクエストを抑制

layout ― 子ルートを囲むレイアウト

+layout.svelte ― children を受け取る

<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import '../app.css';
  import type { Snippet } from 'svelte';
  import Header from '$lib/components/Header.svelte';
  import Footer from '$lib/components/Footer.svelte';

  let { children, data }: { children: Snippet; data: { user: any } } = $props();
</script>

<Header user={data.user} />
<main class="container">
  {@render children()}
</main>
<Footer />

+layout.server.ts ― 共通サーバーデータ

// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  return {
    user: locals.user
      ? { id: locals.user.id, email: locals.user.email }
      : null
  };
};

レイアウトグループ ― (group) で URL に出さないネスト

丸括弧で囲ったディレクトリは URL に含まれません。「ログイン後のレイアウト」と「公開ページのレイアウト」を分離するのに使います。

src/routes/
  (marketing)/
    +layout.svelte         ← 公開ページ用
    +page.svelte           ← /
    pricing/+page.svelte   ← /pricing
  (app)/
    +layout.svelte         ← ダッシュボード用
    +layout.server.ts      ← 認証ガード
    dashboard/+page.svelte ← /dashboard

+error.svelte ― エラー画面

<!-- src/routes/+error.svelte -->
<script lang="ts">
  import { page } from '$app/stores';
</script>

<svelte:head>
  <title>エラー {$page.status}</title>
</svelte:head>

<h1>{$page.status}: {$page.error?.message}</h1>
<a href="/">トップに戻る</a>

任意の load 内で throw error(404, 'Not found') すれば、もっとも近い +error.svelte が表示されます。

+server.ts ― API エンドポイント

+server.ts を置けばそのルートが REST API になります。HTTP メソッドごとに関数を export するだけです。

GET / POST / DELETE のシンプル実装

// src/routes/api/posts/+server.ts
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url }) => {
  const limit = Number(url.searchParams.get('limit') ?? 20);
  const posts = await db.post.findMany({ take: limit });
  return json(posts);
};

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) throw error(401, 'unauthorized');
  const body = await request.json();
  if (!body.title) throw error(400, 'title required');
  const post = await db.post.create({
    data: { title: body.title, authorId: locals.user.id }
  });
  return json(post, { status: 201 });
};

export const DELETE: RequestHandler = async ({ url, locals }) => {
  if (!locals.user) throw error(401, 'unauthorized');
  const id = url.searchParams.get('id');
  if (!id) throw error(400, 'id required');
  await db.post.delete({ where: { id: Number(id) } });
  return new Response(null, { status: 204 });
};

動的パラメータ付きエンドポイント

// src/routes/api/posts/[id]/+server.ts
import { json, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ params }) => {
  const post = await db.post.findUnique({
    where: { id: Number(params.id) }
  });
  if (!post) throw error(404, 'not found');
  return json(post);
};

form actions ― 「JS なしでも動く」フォーム処理

SvelteKit の form actions は、<form method="POST"> をそのまま受け、サーバー側で処理する仕組みです。JavaScript が無効でも動くのが最大の利点で、その上で use:enhance を被せれば SPA 的にも振る舞います(progressive enhancement)。

シンプルな default action

// src/routes/contact/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => ({});

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const email = String(data.get('email') ?? '');
    const message = String(data.get('message') ?? '');

    if (!email.includes('@')) {
      return fail(400, { email, message, error: 'invalid email' });
    }
    // 送信処理 ...
    throw redirect(303, '/contact/thanks');
  }
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
  import type { ActionData } from './$types';
  let { form }: { form: ActionData } = $props();
</script>

<form method="POST">
  <input name="email" type="email" value={form?.email ?? ''} required />
  <textarea name="message">{form?.message ?? ''}</textarea>
  {#if form?.error}<p class="error">{form.error}</p>{/if}
  <button type="submit">送信</button>
</form>

named actions ― 複数の action を持つ

// 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 data = await request.formData();
    const title = String(data.get('title') ?? '').trim();
    if (!title) return fail(400, { title, error: 'title required' });
    await locals.db.todo.create({ data: { title } });
    return { success: true };
  },
  toggle: async ({ request, locals }) => {
    const data = await request.formData();
    const id = Number(data.get('id'));
    const todo = await locals.db.todo.findUnique({ where: { id } });
    if (!todo) return fail(404);
    await locals.db.todo.update({
      where: { id },
      data: { done: !todo.done }
    });
    return { success: true };
  },
  delete: async ({ request, locals }) => {
    const data = await request.formData();
    const id = Number(data.get('id'));
    await locals.db.todo.delete({ where: { id } });
    return { success: true };
  }
};
<form method="POST" action="?/create">
  <input name="title" />
  <button>追加</button>
</form>

<form method="POST" action="?/delete">
  <input type="hidden" name="id" value={todo.id} />
  <button>削除</button>
</form>

use:enhance ― Progressive Enhancement

use:enhance を付けると、フォーム送信が ページ全体リロードなしの fetch ベースになります。JS 無効環境では普通に POST されるため、UX を底上げしつつ堅牢性も維持できます。

<script lang="ts">
  import { enhance } from '$app/forms';
  import { invalidateAll } from '$app/navigation';
  let submitting = $state(false);
</script>

<form
  method="POST"
  action="?/create"
  use:enhance={() => {
    submitting = true;
    return async ({ result, update }) => {
      submitting = false;
      // result.type === 'success' | 'failure' | 'redirect' | 'error'
      if (result.type === 'success') {
        await invalidateAll();
      }
      await update();
    };
  }}
>
  <input name="title" required />
  <button disabled={submitting}>
    {submitting ? '送信中...' : '追加'}
  </button>
</form>

楽観的更新パターン

<script lang="ts">
  import { enhance } from '$app/forms';
  import type { ActionResult } from '@sveltejs/kit';

  let todos = $state<{ id: number; title: string }[]>([]);
</script>

<form
  method="POST"
  action="?/create"
  use:enhance={({ formData, cancel }) => {
    const title = String(formData.get('title'));
    if (!title) { cancel(); return; }
    // 楽観的に追加
    const tempId = -Date.now();
    todos = [...todos, { id: tempId, title }];

    return async ({ result, update }: { result: ActionResult; update: () => Promise<void> }) => {
      if (result.type !== 'success') {
        todos = todos.filter((t) => t.id !== tempId);
      }
      await update({ reset: true });
    };
  }}
>
  <input name="title" />
  <button>追加</button>
</form>

cookies と locals ― セッション管理の基本

cookies の読み書き

// +page.server.ts や +server.ts で
export const load: PageServerLoad = async ({ cookies }) => {
  const theme = cookies.get('theme') ?? 'light';
  return { theme };
};

export const actions: Actions = {
  setTheme: async ({ cookies, request }) => {
    const data = await request.formData();
    const theme = String(data.get('theme') ?? 'light');
    cookies.set('theme', theme, {
      path: '/',
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 365 // 1 year
    });
    return { success: true };
  }
};

app.d.ts ― locals の型定義

// src/app.d.ts
declare global {
  namespace App {
    interface Locals {
      user: { id: number; email: string; role: 'admin' | 'user' } | null;
      sessionId?: string;
    }
    interface PageData {
      user?: App.Locals['user'];
    }
    interface Error {
      code?: string;
    }
    // interface Platform {} // adapter-cloudflare などの platform
  }
}
export {};

hooks.server.ts ― 認証ガードとリクエスト変換

hooks は 全リクエストに対する横断的な処理を書く場所です。認証チェック・ログ・i18n・レスポンスヘッダの付与などに使います。

handle ― リクエスト/レスポンスの中間処理

// src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { verifySession } from '$lib/server/auth';

const authenticate: Handle = async ({ event, resolve }) => {
  const sid = event.cookies.get('sid');
  event.locals.user = sid ? await verifySession(sid) : null;
  return resolve(event);
};

const protectRoutes: Handle = async ({ event, resolve }) => {
  if (event.url.pathname.startsWith('/admin') && !event.locals.user) {
    throw redirect(303, `/login?from=${encodeURIComponent(event.url.pathname)}`);
  }
  return resolve(event);
};

const securityHeaders: Handle = async ({ event, resolve }) => {
  const response = await resolve(event);
  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  return response;
};

export const handle = sequence(authenticate, protectRoutes, securityHeaders);

handleFetch ― SSR 中の fetch を書き換える

// src/hooks.server.ts
import type { HandleFetch } from '@sveltejs/kit';

export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
  // SSR 中だけ内部 API に直接アクセス
  if (request.url.startsWith('https://api.example.com/')) {
    const cookie = event.request.headers.get('cookie');
    if (cookie) request.headers.set('cookie', cookie);
  }
  return fetch(request);
};

handleError ― エラーロギング

// src/hooks.server.ts
import type { HandleServerError } from '@sveltejs/kit';

export const handleError: HandleServerError = ({ error, event, status, message }) => {
  console.error('[server error]', { status, message, url: event.url.pathname, error });
  return {
    message: status === 500 ? 'Internal Server Error' : message,
    code: (error as any)?.code ?? 'UNKNOWN'
  };
};

セッション管理の最小実装例

// src/lib/server/auth.ts
import { db } from './db';

export async function createSession(userId: number) {
  const sid = crypto.randomUUID();
  await db.session.create({
    data: { id: sid, userId, expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) }
  });
  return sid;
}

export async function verifySession(sid: string) {
  const sess = await db.session.findUnique({
    where: { id: sid },
    include: { user: true }
  });
  if (!sess || sess.expiresAt < new Date()) return null;
  return { id: sess.user.id, email: sess.user.email, role: sess.user.role };
}

export async function destroySession(sid: string) {
  await db.session.delete({ where: { id: sid } }).catch(() => {});
}

$app/stores と $app/navigation

$app/stores ― page / navigating / updated

<script lang="ts">
  import { page, navigating, updated } from '$app/stores';
</script>

<p>path: {$page.url.pathname}</p>
<p>params: {JSON.stringify($page.params)}</p>
<p>status: {$page.status}</p>
<p>user: {$page.data.user?.email ?? 'guest'}</p>

{#if $navigating}
  <div class="nav-indicator">
    {$navigating.from?.url.pathname} → {$navigating.to?.url.pathname}
  </div>
{/if}

{#if $updated}
  <p>新しいバージョンがあります</p>
{/if}

$app/navigation ― goto / preload

<script lang="ts">
  import { goto, preloadData, preloadCode, beforeNavigate, afterNavigate } from '$app/navigation';

  async function jump() {
    await preloadData('/dashboard'); // load 関数を先回り実行
    await goto('/dashboard');
  }

  beforeNavigate(({ cancel, to }) => {
    if (to?.url.pathname === '/danger' && !confirm('遷移しますか?')) cancel();
  });

  afterNavigate(({ from, to }) => {
    console.log(`${from?.url.pathname} → ${to?.url.pathname}`);
  });
</script>

<button onclick={jump}>Go to Dashboard</button>
<a href="/heavy" data-sveltekit-preload-data="hover">Heavy Page</a>

レンダリングモード ― CSR / SSR / SSG の切替

SvelteKit はルート単位でレンダリングモードを +page.ts または +layout.ts から制御できます。

SSR を切る / CSR を切る / プリレンダリング

// +page.ts
// SSG: ビルド時に HTML を出力
export const prerender = true;

// SSR を無効化(クライアント側のみ描画)
export const ssr = false;

// CSR(ハイドレーション)を無効化(完全な静的 HTML)
export const csr = false;

// trailingSlash の指定
export const trailingSlash = 'always'; // 'never' | 'ignore'

プリレンダリング対象を動的に決める

// src/routes/blog/[slug]/+page.server.ts
import type { EntryGenerator, PageServerLoad } from './$types';
import { db } from '$lib/server/db';

export const prerender = true;

export const entries: EntryGenerator = async () => {
  const posts = await db.post.findMany({ select: { slug: true } });
  return posts.map((p) => ({ slug: p.slug }));
};

export const load: PageServerLoad = async ({ params }) => {
  const post = await db.post.findUniqueOrThrow({ where: { slug: params.slug } });
  return { post };
};

svelte.config.js と adapter ― 配信先を選ぶ

SvelteKit は「adapter」と呼ばれるプラグインを差し替えるだけで、Vercel / Cloudflare Workers / Node.js / 静的ホスティングなど多様な環境にデプロイできます。

adapter-auto(デフォルト)

// svelte.config.js
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

export default {
  preprocess: vitePreprocess(),
  kit: {
    adapter: adapter(),
    alias: {
      $lib: 'src/lib',
      $components: 'src/lib/components'
    }
  }
};

adapter-vercel

// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';

export default {
  kit: {
    adapter: adapter({
      runtime: 'nodejs20.x',
      regions: ['iad1'],
      memory: 1024,
      maxDuration: 60,
      isr: { expiration: 60 }
    })
  }
};

adapter-cloudflare

// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';

export default {
  kit: {
    adapter: adapter({
      routes: {
        include: ['/*'],
        exclude: ['<all>']
      }
    })
  }
};

adapter-node ― セルフホスト

// svelte.config.js
import adapter from '@sveltejs/adapter-node';

export default {
  kit: {
    adapter: adapter({
      out: 'build',
      precompress: true,
      envPrefix: 'PUBLIC_'
    })
  }
};
npm run build
# build/index.js が生成される
PORT=3000 ORIGIN=https://example.com node build/index.js

adapter-static ― 完全静的サイト

import adapter from '@sveltejs/adapter-static';

export default {
  kit: {
    adapter: adapter({
      pages: 'build',
      assets: 'build',
      fallback: 'index.html', // SPA モード
      precompress: false,
      strict: true
    })
  }
};

環境変数 ― $env/static と $env/dynamic

// サーバー専用 / ビルド時静的注入
import { DATABASE_URL, JWT_SECRET } from '$env/static/private';

// クライアントにも露出する公開変数(PUBLIC_ プレフィックス必須)
import { PUBLIC_API_BASE } from '$env/static/public';

// 実行時に評価したい場合
import { env } from '$env/dynamic/private';
import { env as publicEnv } from '$env/dynamic/public';

console.log(env.DATABASE_URL);
console.log(publicEnv.PUBLIC_API_BASE);
# .env
DATABASE_URL="postgres://..."
JWT_SECRET="xxxxxxxxxx"
PUBLIC_API_BASE="https://api.example.com"

実践レシピ集 ― よくあるユースケース

ログイン → セッション発行 → リダイレクト

// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { createSession } from '$lib/server/auth';
import bcrypt from 'bcryptjs';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request, cookies, url }) => {
    const data = await request.formData();
    const email = String(data.get('email') ?? '');
    const password = String(data.get('password') ?? '');

    const user = await db.user.findUnique({ where: { email } });
    if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
      return fail(401, { email, error: 'メールまたはパスワードが違います' });
    }
    const sid = await createSession(user.id);
    cookies.set('sid', sid, {
      path: '/', httpOnly: true, secure: true, sameSite: 'lax',
      maxAge: 60 * 60 * 24 * 7
    });
    throw redirect(303, url.searchParams.get('from') ?? '/dashboard');
  }
};

ファイルアップロード

// src/routes/upload/+page.server.ts
import { fail } from '@sveltejs/kit';
import { writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import type { Actions } from './$types';

export const actions: Actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const file = data.get('file');
    if (!(file instanceof File) || file.size === 0) {
      return fail(400, { error: 'ファイルを選んでください' });
    }
    if (file.size > 5 * 1024 * 1024) {
      return fail(400, { error: '5MB以下にしてください' });
    }
    const ext = file.name.split('.').pop();
    const name = `${randomUUID()}.${ext}`;
    const buf = Buffer.from(await file.arrayBuffer());
    await writeFile(`static/uploads/${name}`, buf);
    return { success: true, url: `/uploads/${name}` };
  }
};
<form method="POST" enctype="multipart/form-data">
  <input type="file" name="file" accept="image/*" required />
  <button>アップロード</button>
</form>

Server-Sent Events(ストリーミング応答)

// src/routes/api/stream/+server.ts
import type { RequestHandler } from './$types';

export const GET: RequestHandler = ({}) => {
  const stream = new ReadableStream({
    start(controller) {
      let i = 0;
      const id = setInterval(() => {
        controller.enqueue(`data: tick ${++i}nn`);
        if (i >= 10) {
          clearInterval(id);
          controller.close();
        }
      }, 1000);
    }
  });
  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      Connection: 'keep-alive'
    }
  });
};

パラレル load + Streaming SSR

// +page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ fetch }) => {
  // 上は await ―― 必須データ(SSR HTML に含めてからレスポンス開始)
  const user = await fetch('/api/me').then((r) => r.json());

  // 下は await しない ―― Promise のまま返すと SvelteKit が後追いでストリーム
  const recommendations = fetch('/api/recommend').then((r) => r.json());

  return { user, streamed: { recommendations } };
};
<script lang="ts">
  let { data } = $props();
</script>

<h1>こんにちは {data.user.name}</h1>

{#await data.streamed.recommendations}
  <p>おすすめを取得中...</p>
{:then recs}
  <ul>{#each recs as r}<li>{r.title}</li>{/each}</ul>
{/await}

テスト ― Vitest と Playwright

Vitest によるユニットテスト

// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { slugify } from './utils';

describe('slugify', () => {
  it('スペースをハイフンに変換', () => {
    expect(slugify('Hello World')).toBe('hello-world');
  });
  it('全角→英数化', () => {
    expect(slugify('SvelteKit 入門')).toBe('sveltekit');
  });
});

Playwright による E2E テスト

// tests/login.spec.ts
import { expect, test } from '@playwright/test';

test('ログインしてダッシュボードへ', async ({ page }) => {
  await page.goto('/login');
  await page.getByLabel('email').fill('alice@example.com');
  await page.getByLabel('password').fill('password');
  await page.getByRole('button', { name: '送信' }).click();
  await expect(page).toHaveURL('/dashboard');
  await expect(page.getByRole('heading', { name: /こんにちは/ })).toBeVisible();
});

パフォーマンス&運用のベストプラクティス

data-sveltekit-* 属性でプリロード制御

<a href="/heavy" data-sveltekit-preload-data="hover">hover でデータ先読み</a>
<a href="/spa" data-sveltekit-preload-data="tap">クリック直前で先読み</a>
<a href="/static" data-sveltekit-preload-data="off">先読み無効</a>
<a href="/reload" data-sveltekit-reload>完全リロード</a>
<a href="/refresh" data-sveltekit-noscroll>スクロール位置保持</a>

setHeaders ― CDN キャッシュ制御

export const load: PageServerLoad = async ({ setHeaders }) => {
  setHeaders({
    'cache-control': 'public, max-age=60, s-maxage=600, stale-while-revalidate=86400'
  });
  return { /* ... */ };
};

画像最適化(@sveltejs/enhanced-img)

<script lang="ts">
  // vite.config.ts で enhancedImages プラグインを有効化
</script>

<enhanced:img
  src="./hero.jpg"
  alt="hero"
  sizes="(max-width: 768px) 100vw, 768px"
/>

SvelteKit vs Next.js vs React ― 採用判断

SvelteKit を選ぶ理由

  • バンドルサイズ: 同等規模のアプリで Next.js より明確に軽く、Time to Interactive が短い
  • 学習曲線が緩い: HTML/CSS/JS の延長で書ける。JSX を使う React より初心者の習熟が早い
  • form actions + use:enhance の堅牢さ: JS なしでも動く UI を、SPA UX のまま実現できる
  • adapter による柔軟なデプロイ先: 同じソースを Vercel・Cloudflare Workers・Node・静的サイトに切替可能
  • 1社1FW: Pages Router / App Router のような分岐がなく学習対象が1つ

Next.js / React を選ぶ理由

  • エコシステム(コンポーネントライブラリ・教材・求人)が圧倒的に厚い
  • React Server Components や Server Actions など新パラダイムが先行
  • 大規模チームでの実績・知見が豊富

使い分けの目安

┌──────────────────────────────┬────────────────────────────┐
│ ユースケース                     │ 推奨                       │
├──────────────────────────────┼────────────────────────────┤
│ 個人開発・小〜中規模 SaaS         │ SvelteKit                  │
│ 企業の新規プロダクト(中小)        │ SvelteKit / Next.js どちらも│
│ 大企業既存資産が React            │ Next.js                    │
│ 静的サイト+少しのインタラクション  │ SvelteKit(adapter-static) │
│ 巨大エコシステム要件               │ Next.js                    │
│ 学習コストを最小化したい           │ SvelteKit                  │
└──────────────────────────────┴────────────────────────────┘

本記事で学んだことのまとめ

  • Svelte 5 では $state$derived$effect$props など runes によりリアクティビティが明示的になった
  • SvelteKit のルーティングは src/routes/ 配下のファイル名規約(+page.svelte / +page.ts / +page.server.ts / +layout.svelte / +server.ts)で完結
  • データフェッチは load 関数に集約。秘匿情報は必ず .server.ts 側へ
  • フォームは form actions + use:enhance で「JS なしでも動く SPA」が作れる
  • hooks.server.ts で認証ガード・ロギング・セキュリティヘッダを横断的に処理
  • adapter を差し替えるだけで Vercel / Cloudflare / Node / 静的サイトへデプロイ可能
  • レンダリングモードはルート単位で prerender / ssr / csr を切替できる

次に読むべき公式ドキュメント

  • Svelte 5 公式: svelte.dev/docs/svelte ― runes 詳細
  • SvelteKit 公式: svelte.dev/docs/kit ― ルーティング / load / hooks
  • Svelte 5 Migration Guide ― Svelte 4 から 5 への移行ポイント
  • Adapter docs ― 各種ホスティング向けの細かいオプション

本記事のコードはすべて Svelte 5 + SvelteKit 2 + TypeScript 5 の最新版でそのまま動作するように書いています。「コピペで動く実例」を起点に、まずはローカルで npm create svelte から手を動かしてみてください。Svelte は触ってみてはじめて「軽さ」と「書きやすさ」が腹落ちするフレームワークです。

コメント

タイトルとURLをコピーしました