「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 ならではの強み」も最後に整理します。
- SvelteKit とは ― 「コンパイラ駆動」のフルスタックフレームワーク
- プロジェクト作成 ― npm create svelte でゼロから
- Svelte 5 runes ― 言語レベルのリアクティビティ
- Snippets と Component 構文
- Stores ― legacy ストアと runes 風ステート
- ファイルベースルーティング ― +page.svelte と仲間たち
- load 関数 ― データフェッチの正攻法
- layout ― 子ルートを囲むレイアウト
- +error.svelte ― エラー画面
- +server.ts ― API エンドポイント
- form actions ― 「JS なしでも動く」フォーム処理
- cookies と locals ― セッション管理の基本
- hooks.server.ts ― 認証ガードとリクエスト変換
- $app/stores と $app/navigation
- レンダリングモード ― CSR / SSR / SSG の切替
- svelte.config.js と adapter ― 配信先を選ぶ
- 環境変数 ― $env/static と $env/dynamic
- 実践レシピ集 ― よくあるユースケース
- テスト ― Vitest と Playwright
- パフォーマンス&運用のベストプラクティス
- SvelteKit vs Next.js vs React ― 採用判断
- 本記事で学んだことのまとめ
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 を
.svelte1ファイルに書ける。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 は触ってみてはじめて「軽さ」と「書きやすさ」が腹落ちするフレームワークです。

コメント