mcpField
S
sveltekit

sveltekit

github.com/bobmatnyc開発78447 回インストール15 週間前に更新
リポジトリ

sveltekit について

SvelteKit は、Svelte で Web アプリを開発するためのフルスタックフレームワークです。ファイルベースのルーティング、複数のレンダリング方式、フォーム処理、デプロイアダプターを備えています。

主な機能

  1. 01ファイルベースのルーティング
  2. 02型安全なデータ読み込み
  3. 03プログレッシブエンハンスメント対応フォーム
  4. 04SSR、SSG、SPA のレンダリング
  5. 05API ルートとサーバーフック
  6. 06複数のデプロイアダプター

活用シーン

  1. 01動的ルートを使う Web アプリの構築
  2. 02ページデータのサーバーまたはクライアント読み込み
  3. 03段階的に拡張できるフォームの作成
  4. 04SSR または静的生成サイトのデプロイ

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/bobmatnyc/claude-mpm-skills --skill sveltekit

インストール後、Claude Code で/sveltekitを入力して読み込まれたか確認します。

同梱ファイルmetadata.json

詳細

SvelteKit - フルスタック Svelte フレームワーク

概要

SvelteKit は Svelte の公式フルスタックフレームワークであり、ファイルベースルーティング、サーバーサイドレンダリング(SSR)、静的サイト生成(SSG)、プログレッシブエンハンスメントによるフォーム処理、およびあらゆるプラットフォーム向けのデプロイアダプターを提供します。

主な機能:

  • ファイルベースルーティング:src/routes/ ディレクトリ構造からルートを自動生成
  • ロード関数:型安全なデータ取得(+page.ts、+page.server.ts)
  • フォームアクション:プログレッシブエンハンスメントに対応したネイティブフォーム処理
  • SSR/SSG/SPA:ルートごとに制御できる柔軟なレンダリングモード
  • アダプター:Vercel、Netlify、Node.js、Cloudflare などにデプロイ
  • TypeScript ファースト:型安全性を実現する $types からの型生成
  • フック:ミドルウェアのように動作する handle、handleError、handleFetch
  • API ルート:REST エンドポイント用の +server.ts ファイル

インストール:

# Create new SvelteKit project
npm create svelte@latest my-app
cd my-app
npm install
npm run dev -- --open

# Templates: skeleton, demo app, library
# Choices: TypeScript, ESLint, Prettier, Playwright, Vitest

プロジェクト構造

標準的な SvelteKit の構成
my-sveltekit-app/
├── src/
│   ├── routes/                   # File-based routing
│   │   ├── +page.svelte         # / (home page)
│   │   ├── +page.ts             # Universal load function
│   │   ├── +page.server.ts      # Server-only load function
│   │   ├── +layout.svelte       # Shared layout
│   │   ├── +layout.ts           # Layout load function
│   │   ├── +error.svelte        # Error page
│   │   ├── about/
│   │   │   └── +page.svelte     # /about
│   │   ├── blog/
│   │   │   ├── +page.svelte     # /blog (list)
│   │   │   ├── +page.server.ts  # Load posts
│   │   │   └── [slug]/
│   │   │       ├── +page.svelte # /blog/my-post
│   │   │       └── +page.server.ts
│   │   └── api/
│   │       └── posts/
│   │           └── +server.ts   # GET /api/posts
│   ├── lib/
│   │   ├── components/
│   │   ├── server/              # Server-only utilities
│   │   │   └── database.ts
│   │   ├── stores/
│   │   └── utils/
│   ├── hooks.server.ts          # Server hooks
│   ├── hooks.client.ts          # Client hooks
│   ├── app.html                 # HTML template
│   └── app.d.ts                 # TypeScript declarations
├── static/                       # Static assets (robots.txt, favicon)
├── tests/                        # Playwright tests
├── svelte.config.js             # SvelteKit configuration
├── vite.config.ts               # Vite configuration
└── package.json

ファイルベースルーティング

ルートの規則

ファイル名によってルーティングが決まります:

ファイルルート用途
+page.svelte/ページコンポーネント
+page.ts-ユニバーサルロード(クライアント + サーバー)
+page.server.ts-サーバー専用ロード
+layout.svelte-共通レイアウト
+layout.ts-レイアウトロード
+layout.server.ts-サーバーレイアウトロード
+server.ts/api/...API エンドポイント(GET/POST など)
+error.svelte-エラーバウンダリー
基本的なルート
src/routes/
├── +page.svelte              # / (home)
├── about/
│   └── +page.svelte          # /about
├── contact/
│   └── +page.svelte          # /contact
└── pricing/
    └── +page.svelte          # /pricing
動的ルート
src/routes/
└── blog/
    ├── +page.svelte          # /blog (list)
    ├── [slug]/
    │   └── +page.svelte      # /blog/my-post
    └── [category]/
        └── [slug]/
            └── +page.svelte  # /blog/tech/my-post

ルートパラメーターにアクセスする:


<script lang="ts">
  import type { PageData } from './$types';

  let { data } = $props<{ data: PageData }>();
</script>

<article>
  <h1>{data.post.title}</h1>
  <p>{data.post.content}</p>
</article>
オプションパラメーター
src/routes/
└── archive/
    └── [[year]]/
        └── [[month]]/
            └── +page.svelte  # /archive, /archive/2024, /archive/2024/11
// src/routes/archive/[[year]]/[[month]]/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ params }) => {
  const year = params.year || new Date().getFullYear();
  const month = params.month || null;

  return {
    year,
    month,
    posts: await fetchPosts({ year, month })
  };
};
残余パラメーター
src/routes/
└── docs/
    └── [...path]/
        └── +page.svelte      # /docs/guide/intro, /docs/api/reference
// src/routes/docs/[...path]/+page.ts
export const load: PageLoad = async ({ params }) => {
  const path = params.path; // "guide/intro"
  const segments = path.split('/'); // ["guide", "intro"]

  return {
    doc: await fetchDoc(path)
  };
};

ロード関数

ユニバーサルロード(+page.ts)

サーバーとクライアントの両方で実行されます。データ取得には fetch を使用する必要があります。

// src/routes/products/+page.ts
import type { PageLoad } from './$types';

export const load: PageLoad = async ({ fetch, params, url }) => {
  const response = await fetch('/api/products');
  const products = await response.json();

  return {
    products,
    searchQuery: url.searchParams.get('q') || ''
  };
};

// Prerendering options
export const prerender = true; // Static generation
export const ssr = false;      // Disable SSR (SPA mode)
export const csr = true;       // Enable client-side rendering
サーバー専用ロード(+page.server.ts)

サーバー上でのみ実行されます。データベースへの直接アクセスが可能です。

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

export const load: PageServerLoad = async ({ locals, cookies }) => {
  // Check authentication
  if (!locals.user) {
    throw redirect(303, '/login');
  }

  // Direct database query (server-only)
  const stats = await db.query.stats.findFirst({
    where: eq(stats.userId, locals.user.id)
  });

  // Sensitive data stays on server
  const apiKey = process.env.SECRET_API_KEY;
  const data = await fetchPrivateData(apiKey);

  return {
    stats,
    userData: data
  };
};
Promise によるストリーミング
// src/routes/posts/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  return {
    // Immediate data
    featured: await db.posts.findMany({ where: { featured: true } }),

    // Streamed data (loads async)
    recent: db.posts.findMany({ orderBy: { createdAt: 'desc' } }),
    popular: db.posts.findMany({ orderBy: { views: 'desc' } })
  };
};

<script lang="ts">
  import type { PageData } from './$types';

  let { data } = $props<{ data: PageData }>();
</script>

<h2>Featured</h2>
{#each data.featured as post}
  <article>{post.title}</article>
{/each}

<h2>Recent</h2>
{#await data.recent}
  <p>Loading recent posts...</p>
{:then posts}
  {#each posts as post}
    <article>{post.title}</article>
  {/each}
{/await}

<h2>Popular</h2>
{#await data.popular}
  <p>Loading popular posts...</p>
{:then posts}
  {#each posts as post}
    <article>{post.title}</article>
  {/each}
{/await}

レイアウト

共通レイアウト

<script lang="ts">
  import Header from '$lib/components/Header.svelte';
  import Footer from '$lib/components/Footer.svelte';
  import type { LayoutData } from './$types';

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

<div class="app">
  <Header user={data.user} />

  <main>
    {@render children()}
  </main>

  <Footer />
</div>

<style>
  .app {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }

  main {
    flex: 1;
  }
</style>
レイアウトロード
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  return {
    user: locals.user || null
  };
};
ネストされたレイアウト
src/routes/
├── +layout.svelte              # Root layout (all pages)
├── (app)/
│   ├── +layout.svelte          # App layout (dashboard, settings)
│   ├── dashboard/
│   │   └── +page.svelte        # Uses: root + app layouts
│   └── settings/
│       └── +page.svelte
└── (marketing)/
    ├── +layout.svelte          # Marketing layout (about, pricing)
    ├── about/
    │   └── +page.svelte        # Uses: root + marketing layouts
    └── pricing/
        └── +page.svelte

(name) を使ったレイアウトグループは URL 構造に影響しません:

  • /(app)/dashboard ではなく /dashboard
レイアウトから抜ける

<script>
  let { children } = $props();
</script>

<div class="admin">
  {@render children()}
</div>


<form method="POST">
  <input name="email" type="email" />
  <input name="password" type="password" />
  <button type="submit">Login</button>
</form>

フォームアクション

基本的なフォームアクション
// src/routes/login/+page.server.ts
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';

export const actions = {
  // Default action (form without action attribute)
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const email = data.get('email');
    const password = data.get('password');

    if (!email || !password) {
      return fail(400, { email, missing: true });
    }

    const user = await authenticateUser(email, password);
    if (!user) {
      return fail(401, { email, incorrect: true });
    }

    cookies.set('session', user.sessionToken, {
      path: '/',
      httpOnly: true,
      sameSite: 'strict',
      secure: process.env.NODE_ENV === 'production',
      maxAge: 60 * 60 * 24 * 7 // 1 week
    });

    throw redirect(303, '/dashboard');
  }
} satisfies Actions;

<script lang="ts">
  import type { ActionData } from './$types';

  let { form } = $props<{ form?: ActionData }>();
</script>

<form method="POST">
  <input
    name="email"
    type="email"
    value={form?.email ?? ''}
    required
  />

  <input name="password" type="password" required />

  {#if form?.missing}
    <p class="error">Please fill in all fields</p>
  {/if}

  {#if form?.incorrect}
    <p class="error">Invalid email or password</p>
  {/if}

  <button type="submit">Log in</button>
</form>
名前付きアクション
// src/routes/todos/+page.server.ts
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  return {
    todos: await db.todos.findMany()
  };
};

export const actions = {
  create: async ({ request }) => {
    const data = await request.formData();
    const text = data.get('text');

    if (!text) {
      return fail(400, { text, missing: true });
    }

    await db.todos.create({ data: { text, done: false } });
    return { success: true };
  },

  toggle: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id');

    const todo = await db.todos.findUnique({ where: { id } });
    await db.todos.update({
      where: { id },
      data: { done: !todo.done }
    });

    return { toggled: true };
  },

  delete: async ({ request }) => {
    const data = await request.formData();
    const id = data.get('id');

    await db.todos.delete({ where: { id } });
    return { deleted: true };
  }
} satisfies Actions;

<script lang="ts">
  import type { PageData, ActionData } from './$types';

  let { data, form } = $props<{ data: PageData, form?: ActionData }>();
</script>

<h1>Todos</h1>

{#if form?.success}
  <p class="success">Todo created!</p>
{/if}

<form method="POST" action="?/create">
  <input name="text" placeholder="What needs to be done?" required />
  <button type="submit">Add</button>
</form>

{#each data.todos as todo}
  <div>
    <form method="POST" action="?/toggle">
      <input type="hidden" name="id" value={todo.id} />
      <input
        type="checkbox"
        checked={todo.done}
        onchange={(e) => e.currentTarget.form?.requestSubmit()}
      />
      <span class:done={todo.done}>{todo.text}</span>
    </form>

    <form method="POST" action="?/delete">
      <input type="hidden" name="id" value={todo.id} />
      <button type="submit">Delete</button>
    </form>
  </div>
{/each}

<style>
  .done {
    text-decoration: line-through;
    opacity: 0.6;
  }
</style>
プログレッシブエンハンスメント

<script lang="ts">
  import { enhance } from '$app/forms';
  import type { PageData, ActionData } from './$types';

  let { data, form } = $props<{ data: PageData, form?: ActionData }>();
  let isLoading = $state(false);
</script>

<form
  method="POST"
  use:enhance={() => {
    isLoading = true;

    return async ({ update }) => {
      await update();
      isLoading = false;
    };
  }}
>
  <input name="query" placeholder="Search..." />
  <button type="submit" disabled={isLoading}>
    {isLoading ? 'Searching...' : 'Search'}
  </button>
</form>

{#if form?.results}
  <ul>
    {#each form.results as result}
      <li>{result.title}</li>
    {/each}
  </ul>
{/if}

API ルート(+server.ts)

REST エンドポイント
// src/routes/api/posts/+server.ts
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ url }) => {
  const limit = Number(url.searchParams.get('limit')) || 10;
  const offset = Number(url.searchParams.get('offset')) || 0;

  const posts = await db.posts.findMany({
    take: limit,
    skip: offset,
    orderBy: { createdAt: 'desc' }
  });

  return json(posts);
};

export const POST: RequestHandler = async ({ request, locals }) => {
  if (!locals.user) {
    return json({ error: 'Unauthorized' }, { status: 401 });
  }

  const data = await request.json();

  const post = await db.posts.create({
    data: {
      title: data.title,
      content: data.content,
      authorId: locals.user.id
    }
  });

  return json(post, { status: 201 });
};
動的 API ルート
// src/routes/api/posts/[id]/+server.ts
import { error, json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';

export const GET: RequestHandler = async ({ params }) => {
  const post = await db.posts.findUnique({
    where: { id: params.id }
  });

  if (!post) {
    throw error(404, 'Post not found');
  }

  return json(post);
};

export const PATCH: RequestHandler = async ({ params, request, locals }) => {
  if (!locals.user) {
    throw error(401, 'Unauthorized');
  }

  const post = await db.posts.findUnique({ where: { id: params.id } });

  if (post.authorId !== locals.user.id) {
    throw error(403, 'Forbidden');
  }

  const data = await request.json();
  const updated = await db.posts.update({
    where: { id: params.id },
    data: { title: data.title, content: data.content }
  });

  return json(updated);
};

export const DELETE: RequestHandler = async ({ params, locals }) => {
  if (!locals.user) {
    throw error(401, 'Unauthorized');
  }

  const post = await db.posts.findUnique({ where: { id: params.id } });

  if (post.authorId !== locals.user.id) {
    throw error(403, 'Forbidden');
  }

  await db.posts.delete({ where: { id: params.id } });

  return new Response(null, { status: 204 });
};

フック

サーバーフック(hooks.server.ts)
// src/hooks.server.ts
import { redirect, type Handle } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';

// Authentication middleware
const auth: Handle = async ({ event, resolve }) => {
  const sessionToken = event.cookies.get('session');

  if (sessionToken) {
    event.locals.user = await getUserFromSession(sessionToken);
  }

  return resolve(event);
};

// Logging middleware
const logging: Handle = async ({ event, resolve }) => {
  const start = Date.now();
  const response = await resolve(event);
  const duration = Date.now() - start;

  console.log(`${event.request.method} ${event.url.pathname} ${response.status} ${duration}ms`);

  return response;
};

// Protected routes middleware
const protect: Handle = async ({ event, resolve }) => {
  if (event.url.pathname.startsWith('/admin')) {
    if (!event.locals.user?.isAdmin) {
      throw redirect(303, '/login');
    }
  }

  if (event.url.pathname.startsWith('/dashboard')) {
    if (!event.locals.user) {
      throw redirect(303, '/login');
    }
  }

  return resolve(event);
};

// Combine hooks in sequence
export const handle = sequence(auth, logging, protect);

// Error handling
export const handleError = async ({ error, event }) => {
  console.error('Error:', error);

  return {
    message: 'An unexpected error occurred',
    code: error?.code ?? 'UNKNOWN'
  };
};

// Fetch handling (modify requests)
export const handleFetch = async ({ request, fetch }) => {
  // Add auth headers to internal API calls
  if (request.url.startsWith('https://api.example.com')) {
    request.headers.set('Authorization', `Bearer ${process.env.API_TOKEN}`);
  }

  return fetch(request);
};
クライアントフック(hooks.client.ts)
// src/hooks.client.ts
import type { HandleClientError } from '@sveltejs/kit';

export const handleError: HandleClientError = async ({ error, event }) => {
  console.error('Client error:', error);

  // Send to error tracking service
  if (typeof window !== 'undefined') {
    // Sentry, LogRocket, etc.
  }

  return {
    message: 'Something went wrong',
  };
};

環境変数

静的環境変数
// src/lib/config.ts
import { env } from '$env/static/public';
import { env as privateEnv } from '$env/static/private';

// Public variables (available in browser)
export const PUBLIC_API_URL = env.PUBLIC_API_URL;
export const PUBLIC_SITE_NAME = env.PUBLIC_SITE_NAME;

// Private variables (server-only)
export const DATABASE_URL = privateEnv.DATABASE_URL;
export const SECRET_KEY = privateEnv.SECRET_KEY;
動的環境変数
// src/routes/+page.server.ts
import { env } from '$env/dynamic/private';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async () => {
  // Can change at runtime
  const apiUrl = env.API_URL;

  return {
    data: await fetch(apiUrl).then(r => r.json())
  };
};

環境ファイル(.env):

# Public (exposed to browser)
PUBLIC_API_URL=https://api.example.com
PUBLIC_ANALYTICS_ID=UA-123456789

# Private (server-only)
DATABASE_URL=postgres://localhost:5432/mydb
SECRET_KEY=super-secret-key
STRIPE_SECRET_KEY=sk_live_abc123

プリレンダリングと SSR

プリレンダリングのオプション
// src/routes/blog/+page.ts
export const prerender = true; // Prerender at build time
export const ssr = true;        // Server-side render (default)
export const csr = true;        // Client-side render (default)

サイト全体をプリレンダリングする:

// svelte.config.js
export default {
  kit: {
    prerender: {
      entries: ['*'],
      crawl: true
    }
  }
};
動的プリレンダリング
// src/routes/blog/[slug]/+page.server.ts
import type { EntryGenerator, PageServerLoad } from './$types';

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

  return { post };
};

// Generate static pages for all posts at build time
export const entries: EntryGenerator = async () => {
  const posts = await db.posts.findMany();

  return posts.map(post => ({
    slug: post.slug
  }));
};

export const prerender = true;

アダプター

Vercel アダプター
npm install -D @sveltejs/adapter-vercel
// svelte.config.js
import adapter from '@sveltejs/adapter-vercel';

export default {
  kit: {
    adapter: adapter({
      runtime: 'edge', // or 'nodejs'
      regions: ['iad1', 'sfo1'],
      split: false
    })
  }
};
Netlify アダプター
npm install -D @sveltejs/adapter-netlify
// svelte.config.js
import adapter from '@sveltejs/adapter-netlify';

export default {
  kit: {
    adapter: adapter({
      edge: false, // true for edge functions
      split: false
    })
  }
};
Node アダプター
npm install -D @sveltejs/adapter-node
// svelte.config.js
import adapter from '@sveltejs/adapter-node';

export default {
  kit: {
    adapter: adapter({
      out: 'build',
      precompress: true,
      envPrefix: 'MY_'
    })
  }
};

本番サーバーを実行する:

npm run build
node build
静的アダプター(SSG)
npm install -D @sveltejs/adapter-static
// svelte.config.js
import adapter from '@sveltejs/adapter-static';

export default {
  kit: {
    adapter: adapter({
      pages: 'build',
      assets: 'build',
      fallback: '200.html', // SPA fallback
      precompress: false
    })
  }
};
Cloudflare Pages
npm install -D @sveltejs/adapter-cloudflare
// svelte.config.js
import adapter from '@sveltejs/adapter-cloudflare';

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

テスト

Vitest によるユニットテスト
// src/lib/utils.test.ts
import { describe, it, expect } from 'vitest';
import { formatDate } from './utils';

describe('formatDate', () => {
  it('formats date correctly', () => {
    const date = new Date('2024-01-15');
    expect(formatDate(date)).toBe('January 15, 2024');
  });
});
コンポーネントテスト
// src/lib/components/Button.test.ts
import { render, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import Button from './Button.svelte';

describe('Button', () => {
  it('renders with text', () => {
    const { getByText } = render(Button, {
      props: { text: 'Click me' }
    });

    expect(getByText('Click me')).toBeInTheDocument();
  });

  it('calls onclick handler', async () => {
    const handleClick = vi.fn();
    const { getByText } = render(Button, {
      props: { text: 'Click me', onclick: handleClick }
    });

    const button = getByText('Click me');
    await fireEvent.click(button);

    expect(handleClick).toHaveBeenCalledOnce();
  });
});
Playwright による E2E テスト
// tests/login.test.ts
import { expect, test } from '@playwright/test';

test('user can log in', async ({ page }) => {
  await page.goto('/login');

  await page.fill('input[name="email"]', '[email protected]');
  await page.fill('input[name="password"]', 'password123');
  await page.click('button[type="submit"]');

  await expect(page).toHaveURL('/dashboard');
  await expect(page.locator('h1')).toContainText('Dashboard');
});

test('login validation works', async ({ page }) => {
  await page.goto('/login');

  await page.click('button[type="submit"]');

  await expect(page.locator('.error')).toContainText('Please fill in all fields');
});
ロード関数のテスト
// src/routes/blog/+page.server.test.ts
import { describe, it, expect, vi } from 'vitest';
import { load } from './+page.server';

vi.mock('$lib/server/database', () => ({
  db: {
    posts: {
      findMany: vi.fn(() => Promise.resolve([
        { id: '1', title: 'Post 1' },
        { id: '2', title: 'Post 2' }
      ]))
    }
  }
}));

describe('blog page load', () => {
  it('loads posts', async () => {
    const result = await load({ params: {}, url: new URL('http://localhost') } as any);

    expect(result.posts).toHaveLength(2);
    expect(result.posts[0].title).toBe('Post 1');
  });
});

高度なパターン

並列ロード
// src/routes/dashboard/+layout.server.ts
import type { LayoutServerLoad } from './$types';

export const load: LayoutServerLoad = async ({ locals }) => {
  // Load data in parallel
  const [user, notifications, settings] = await Promise.all([
    db.users.findUnique({ where: { id: locals.user.id } }),
    db.notifications.findMany({ where: { userId: locals.user.id } }),
    db.settings.findUnique({ where: { userId: locals.user.id } })
  ]);

  return {
    user,
    notifications,
    settings
  };
};
依存関係のあるロード
// src/routes/profile/[username]/+page.server.ts
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ params, parent }) => {
  // Wait for parent layout data
  const { user } = await parent();

  const profile = await db.profiles.findUnique({
    where: { username: params.username }
  });

  if (!profile) {
    throw error(404, 'Profile not found');
  }

  // Load posts only if profile exists
  const posts = await db.posts.findMany({
    where: { authorId: profile.id },
    orderBy: { createdAt: 'desc' }
  });

  return {
    profile,
    posts,
    isOwnProfile: user?.id === profile.id
  };
};
無効化と再読み込み
<script lang="ts">
  import { invalidate, invalidateAll } from '$app/navigation';
  import { page } from '$app/stores';

  async function refresh() {
    // Reload current page data
    await invalidateAll();
  }

  async function refreshPosts() {
    // Reload specific data
    await invalidate('/api/posts');
  }

  async function refreshUser() {
    // Reload data depending on specific URL
    await invalidate(url => url.pathname.startsWith('/api/user'));
  }
</script>

<button onclick={refresh}>Refresh All</button>
<button onclick={refreshPosts}>Refresh Posts</button>
ページオプション
// src/routes/admin/+page.ts
export const ssr = false;      // Disable server-side rendering
export const csr = true;       // Enable client-side rendering
export const prerender = false; // Disable prerendering
export const trailingSlash = 'always'; // /page/ instead of /page

デプロイ例

Vercel へのデプロイ
# Install Vercel CLI
npm install -g vercel

# Login
vercel login

# Deploy
vercel

# Production deploy
vercel --prod

vercel.json:

{
  "buildCommand": "npm run build",
  "devCommand": "npm run dev",
  "framework": "sveltekit"
}
Docker へのデプロイ(Node アダプター)

Dockerfile:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
RUN npm prune --production

FROM node:20-alpine

WORKDIR /app
COPY --from=builder /app/build build/
COPY --from=builder /app/node_modules node_modules/
COPY package.json .

EXPOSE 3000
ENV NODE_ENV=production
CMD ["node", "build"]
docker build -t my-sveltekit-app .
docker run -p 3000:3000 my-sveltekit-app
静的ホスティング(Netlify、GitHub Pages)
# Build static site
npm run build

# Output in build/ directory
# Deploy build/ to static host

netlify.toml:

[build]
  command = "npm run build"
  publish = "build"

[[redirects]]
  from = "/*"
  to = "/200.html"
  status = 200

ベストプラクティス

  1. 機密性の高い操作には+page.server.tsを使用する - シークレットはサーバー側に保持します
  2. プログレッシブエンハンスメントを活用する - JavaScript がなくてもフォームは動作します
  3. 型安全性には$typesを使用する - SvelteKit が型を自動生成します
  4. エラーバウンダリーを実装する - 適切にエラーを処理するには+error.svelteを使用します
  5. 画像を最適化する - 自動最適化には@sveltejs/enhanced-imgを使用します
  6. プリレンダリングを有効にする - 静的ページはより高速で、コストも抑えられます
  7. 並列読み込みを使用する - 並行してデータを取得するにはPromise.all()を使用します
  8. フォームデータを検証する - スキーマ検証にはZodなどを使用します
  9. セキュリティヘッダーを設定する - CSPやCORSなどにはフックを使用します
  10. Playwrightでテストする - E2Eテストでリグレッションを防ぎます

よく使われるパターン

認証フロー
// src/routes/login/+page.server.ts
export const actions = {
  default: async ({ request, cookies }) => {
    const data = await request.formData();
    const user = await authenticate(data.get('email'), data.get('password'));

    cookies.set('session', user.sessionToken, {
      path: '/',
      httpOnly: true,
      sameSite: 'strict',
      secure: true,
      maxAge: 60 * 60 * 24 * 7
    });

    throw redirect(303, '/dashboard');
  }
};
// src/hooks.server.ts
export const handle: Handle = async ({ event, resolve }) => {
  const sessionToken = event.cookies.get('session');
  if (sessionToken) {
    event.locals.user = await getUserFromSession(sessionToken);
  }
  return resolve(event);
};
保護されたルート
// src/routes/dashboard/+page.server.ts
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals }) => {
  if (!locals.user) {
    throw redirect(303, '/login');
  }

  return {
    user: locals.user
  };
};
フォーム検証
// src/routes/register/+page.server.ts
import { fail } from '@sveltejs/kit';
import { z } from 'zod';
import type { Actions } from './$types';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  confirmPassword: z.string()
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword']
});

export const actions = {
  default: async ({ request }) => {
    const data = await request.formData();
    const formData = Object.fromEntries(data);

    const result = schema.safeParse(formData);

    if (!result.success) {
      return fail(400, {
        errors: result.error.flatten().fieldErrors,
        data: formData
      });
    }

    await createUser(result.data);
    throw redirect(303, '/login');
  }
} satisfies Actions;

リソース

まとめ

  • SvelteKitはSvelte公式のフルスタックフレームワークです
  • +page.svelte、+layout.svelte、+server.tsを使ったファイルベースのルーティング
  • ロード関数により、型安全なデータ取得が可能です(ユニバーサルおよびサーバー専用)
  • フォームアクションにより、ネイティブHTMLフォームでプログレッシブエンハンスメントを実現できます
  • prerender、ssr、csrを使ってルートごとに制御できるSSR/SSG/SPAモード
  • アダプターにより、あらゆるプラットフォーム(Vercel、Netlify、Node、Cloudflare、静的環境)にデプロイできます
  • フックにより、認証、ロギング、エラー処理などのミドルウェアのような機能を利用できます
  • TypeScriptを第一に考えた設計で、$typesを自動生成し、完全な型安全性を実現します
  • $env/staticモジュールと$env/dynamicモジュールを使った環境変数
  • Vitest(ユニットテスト)とPlaywright(E2E)を使ったテスト

コメント