T-CREATOR

SvelteKit でルーティングを自在に操る

SvelteKit でルーティングを自在に操る

SvelteKit でルーティングを自在に操る

近年の Web アプリケーション開発において、ルーティング機能は欠かせない要素となっています。特に SvelteKit が提供するファイルベースルーティングシステムは、開発者の生産性を大幅に向上させる革新的な仕組みです。

この記事では、SvelteKit のルーティング機能を基礎から応用まで段階的に解説いたします。実際のコード例を交えながら、より効率的で柔軟なルーティング実装を習得していただけるでしょう。

背景

SvelteKit のルーティングシステムの概要

SvelteKit のルーティングシステムは、ファイルシステムの構造がそのまま URL の構造に対応する「ファイルベースルーティング」を採用しています。この設計により、開発者は複雑なルーティング設定を書くことなく、直感的に Web アプリケーションの構造を設計できます。

従来の Web フレームワークでは、ルーティングテーブルを手動で定義する必要がありました。しかし、SvelteKit ではファイルとフォルダの配置だけで、自動的にルートが生成される仕組みです。

以下の図は、SvelteKit におけるファイル構造と URL 構造の関係を示しています。

mermaidflowchart LR
    src["src/routes"] --> about["about/+page.svelte"]
    src --> blog["blog/+page.svelte"]
    blog --> post("blog/[slug]/+page.svelte")
    src --> contact["contact/+page.svelte"]

    about --> aboutUrl["/about"]
    blog --> blogUrl["/blog"]
    post --> postUrl["/blog/first-post"]
    contact --> contactUrl["/contact"]

この図では、左側がファイル構造、右側が対応する URL を表現しています。ブラケット記法 [slug] により動的ルートが生成されることがわかります。

ファイルベースルーティングの仕組み

SvelteKit では、src​/​routes ディレクトリ配下のファイル構造が自動的にルートとして認識されます。特別なファイル名規則により、ページコンポーネント、レイアウト、サーバー側処理などを定義可能です。

主要なファイル命名規則は以下のとおりです:

ファイル名役割
+page.svelteページコンポーネント
+layout.svelteレイアウトコンポーネント
+page.server.jsサーバー側データロード
+page.jsクライアント・サーバー共通データロード
+error.svelteエラーページコンポーネント

この規則により、開発者は一目でファイルの役割を理解でき、プロジェクトの保守性が向上します。

他フレームワークとの比較と SvelteKit の特徴

SvelteKit のルーティングシステムは、Next.js や Nuxt.js と同様にファイルベースルーティングを採用していますが、いくつかの独自の特徴があります。

mermaidgraph TB
    subgraph "SvelteKit"
        sk1[+page.svelte]
        sk2[+layout.svelte]
        sk3[+page.server.js]
        sk4["ファイル名規則明確"]
    end

    subgraph "Next.js"
        nx1[index.js]
        nx2[_app.js]
        nx3[getServerSideProps]
        nx4["pages/api統合"]
    end

    subgraph "Nuxt.js"
        nu1[index.vue]
        nu2[layouts/default.vue]
        nu3[asyncData]
        nu4["設定ファイル依存"]
    end

SvelteKit の主な特徴は以下のとおりです:

明確なファイル命名規則: + プレフィックスにより、フレームワーク固有のファイルが一目で識別できます。これにより、プロジェクトファイルとフレームワークファイルの区別が明確になります。

柔軟なレンダリング戦略: ページごとに SSR、CSR、プリレンダリングを選択できる設計となっています。この柔軟性により、パフォーマンス要件に応じた最適化が可能です。

型安全性の向上: TypeScript との統合により、ルートパラメータやデータ型の安全性が保証されます。

課題

複雑なルーティング要件への対応

実際の Web アプリケーション開発では、シンプルな静的ルートだけでは対応できない複雑な要件に直面することがあります。例えば、EC サイトでは商品カテゴリの階層構造、ブログサイトでは記事のタグやカテゴリ別表示など、多様なルーティングパターンが必要になります。

従来のルーティングシステムでは、これらの要件を満たすために複雑な設定ファイルを作成する必要がありました。しかし、SvelteKit のファイルベースルーティングでも、適切な知識がなければ効率的な実装は困難です。

以下の図は、複雑なルーティング要件の例を示しています。

mermaidflowchart TD
    root["/"] --> shop["/shop"]
    shop --> category["/shop/[category]"]
    category --> subcategory["/shop/[category]/[subcategory]"]
    subcategory --> product["/shop/[category]/[subcategory]/[id]"]

    root --> blog["/blog"]
    blog --> tag["/blog/tag/[tag]"]
    blog --> post["/blog/[slug]"]

    root --> user["/user"]
    user --> profile["/user/[id]/profile"]
    user --> settings["/user/[id]/settings"]
    user --> orders["/user/[id]/orders"]

この図では、複数レベルの動的パラメータや、異なる目的を持つルート群が混在していることがわかります。このような複雑な構造を効率的に管理することが開発者の課題となります。

動的ルーティングでのパラメータ処理

動的ルーティングでは、URL からパラメータを取得し、適切にバリデーションと処理を行う必要があります。単純な文字列パラメータだけでなく、数値、日付、配列など、様々な型のパラメータを扱う場面があります。

特に以下のような課題が頻繁に発生します:

型安全性の確保: URL パラメータは常に文字列として取得されるため、適切な型変換とバリデーションが必要です。

パラメータの組み合わせ検証: 複数のパラメータが組み合わさった場合の整合性チェック処理です。

国際化対応: 多言語対応サイトでは、言語パラメータとコンテンツパラメータの組み合わせ処理が複雑になります。

プリレンダリングと SSR でのルーティング最適化

SvelteKit では、ページごとに異なるレンダリング戦略を選択できますが、適切な選択を行わなければパフォーマンス低下や SEO 問題が発生する可能性があります。

主な課題は以下のとおりです:

レンダリング戦略の選択: 静的サイト生成(SSG)、サーバーサイドレンダリング(SSR)、クライアントサイドレンダリング(CSR)のうち、どれを選択すべきか判断が困難な場合があります。

データフェッチング最適化: サーバー側とクライアント側でのデータ取得処理の重複を避け、効率的なデータフローを実現する必要があります。

SEO 対応: 検索エンジン最適化を考慮したルーティング設計とメタデータ管理が求められます。

解決策

ファイルベースルーティングの基本パターン

SvelteKit のファイルベースルーティングを効果的に活用するには、基本的なディレクトリ構造とファイル命名規則を理解することが重要です。

基本的な静的ルートの作成方法から見ていきましょう。

typescript// src/routes/+page.svelte
<script>
  // ホームページのロジック
  let title = 'SvelteKit ホームページ';
</script>

<svelte:head>
  <title>{title}</title>
</svelte:head>

<main>
  <h1>ようこそ SvelteKit へ</h1>
  <p>ファイルベースルーティングの世界をお楽しみください。</p>
</main>

上記のコードは、​/​ ルート(ホームページ)を定義しています。+page.svelte ファイルを src​/​routes ディレクトリに配置するだけで、自動的にルートが生成されます。

次に、ネストした静的ルートの実装方法です。

typescript// src/routes/about/+page.svelte
<script>
  let companyInfo = {
    name: '株式会社サンプル',
    founded: '2023年',
    description: 'SvelteKitを活用したWebサービス開発'
  };
</script>

<main>
  <h1>会社概要</h1>
  <div class="info-card">
    <h2>{companyInfo.name}</h2>
    <p>設立: {companyInfo.founded}</p>
    <p>{companyInfo.description}</p>
  </div>
</main>

<style>
  .info-card {
    padding: 2rem;
    border: 1px solid #e1e5e9;
    border-radius: 8px;
    margin-top: 1rem;
  }
</style>

このファイルを src​/​routes​/​about​/​+page.svelte に配置することで、​/​about ルートが自動生成されます。

動的ルート・キャッチオールルート

動的ルートは、URL の一部をパラメータとして扱うルーティング機能です。ブラケット記法 [param] を使用して実装できます。

基本的な動的ルートの実装例です:

typescript// src/routes/blog/[slug]/+page.svelte
<script>
  import { page } from '$app/stores';

  // URLパラメータから slug を取得
  $: slug = $page.params.slug;

  // ページタイトルの動的生成
  $: title = `ブログ記事: ${slug}`;
</script>

<svelte:head>
  <title>{title}</title>
</svelte:head>

<main>
  <h1>記事タイトル</h1>
  <p>記事スラッグ: {slug}</p>
  <article>
    <!-- 記事内容がここに表示されます -->
  </article>
</main>

サーバー側でのデータロード処理も組み合わせてみましょう。

typescript// src/routes/blog/[slug]/+page.server.js
import { error } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
  const { slug } = params;

  try {
    // データベースやAPIから記事データを取得
    const article = await fetchArticleBySlug(slug);

    if (!article) {
      throw error(404, '記事が見つかりません');
    }

    return {
      article,
      meta: {
        title: article.title,
        description: article.excerpt,
      },
    };
  } catch (err) {
    throw error(500, 'サーバーエラーが発生しました');
  }
}

// 記事データを取得する関数(例)
async function fetchArticleBySlug(slug) {
  // 実際の実装では、データベースやCMSから取得
  const articles = {
    'first-post': {
      title: '最初の記事',
      content: '記事の内容です...',
      excerpt: '最初の記事の概要',
    },
  };

  return articles[slug] || null;
}

キャッチオールルート([...rest])を使用すると、複数レベルのパスを一度に処理できます。

typescript// src/routes/docs/[...path]/+page.svelte
<script>
  import { page } from '$app/stores';

  // パス配列を取得
  $: pathSegments = $page.params.path?.split('/') || [];
  $: currentPath = `/${pathSegments.join('/')}`;
</script>

<main>
  <nav class="breadcrumb">
    <a href="/docs">ドキュメント</a>
    {#each pathSegments as segment, index}
      <span class="separator">></span>
      <a href="/docs/{pathSegments.slice(0, index + 1).join('/')}">{segment}</a>
    {/each}
  </nav>

  <h1>ドキュメントページ</h1>
  <p>現在のパス: {currentPath}</p>
</main>

<style>
  .breadcrumb {
    margin-bottom: 2rem;
  }

  .separator {
    margin: 0 0.5rem;
    color: #666;
  }
</style>

ルートガード・リダイレクト機能

認証が必要なページや、条件に応じてリダイレクトを行う機能を実装してみましょう。

まず、認証チェックを行うルートガードの実装です:

typescript// src/routes/admin/+layout.server.js
import { redirect } from '@sveltejs/kit';

/** @type {import('./$types').LayoutServerLoad} */
export async function load({ cookies, url }) {
  const sessionToken = cookies.get('session');

  if (!sessionToken) {
    // ログインページにリダイレクト
    throw redirect(
      302,
      `/login?redirect=${encodeURIComponent(url.pathname)}`
    );
  }

  try {
    const user = await validateSession(sessionToken);

    if (!user.isAdmin) {
      throw redirect(302, '/unauthorized');
    }

    return {
      user,
    };
  } catch (error) {
    // セッションが無効な場合
    cookies.delete('session', { path: '/' });
    throw redirect(302, '/login');
  }
}

async function validateSession(token) {
  // セッション検証ロジック
  // 実際の実装では、JWTの検証やデータベースでの確認を行う
  if (token === 'valid-admin-token') {
    return {
      id: 1,
      name: '管理者',
      isAdmin: true,
    };
  }
  throw new Error('Invalid session');
}

条件に応じたリダイレクト処理の実装例:

typescript// src/routes/profile/+page.server.js
import { redirect } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ cookies, url }) {
  const user = await getCurrentUser(cookies);

  if (!user) {
    // 未ログインユーザーをログインページにリダイレクト
    throw redirect(
      302,
      `/login?redirect=${encodeURIComponent(url.pathname)}`
    );
  }

  // プロフィール未設定の場合、設定ページにリダイレクト
  if (!user.profileCompleted) {
    throw redirect(302, '/profile/setup');
  }

  return {
    user,
    profileData: await fetchUserProfile(user.id),
  };
}

async function getCurrentUser(cookies) {
  const token = cookies.get('auth_token');
  if (!token) return null;

  try {
    return await verifyAuthToken(token);
  } catch {
    return null;
  }
}

以下の図は、ルートガードとリダイレクト処理のフローを示しています。

mermaidsequenceDiagram
    participant User as ユーザー
    participant Guard as ルートガード
    participant Auth as 認証システム
    participant Page as ページ

    User->>Guard: ページアクセス
    Guard->>Auth: セッション確認

    alt 認証済み
        Auth-->>Guard: 有効なセッション
        Guard->>Page: アクセス許可
        Page-->>User: ページ表示
    else 未認証
        Auth-->>Guard: 無効なセッション
        Guard-->>User: ログインページにリダイレクト
    end

この図では、ユーザーがページにアクセスした際の認証フローが示されており、セッションの有効性に応じて適切な処理が実行されることがわかります。

具体例

基本的な静的ルート実装

実際の Web サイトで使用される基本的なページ構成を実装してみましょう。コーポレートサイトを例にした静的ルートの実装です。

typescript// src/routes/+layout.svelte
<script>
  import '../app.css';
  import Header from '$lib/components/Header.svelte';
  import Footer from '$lib/components/Footer.svelte';
</script>

<div class="app">
  <Header />

  <main class="main-content">
    <slot />
  </main>

  <Footer />
</div>

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

  .main-content {
    flex: 1;
    padding: 2rem;
    max-width: 1200px;
    margin: 0 auto;
  }
</style>

ヘッダーコンポーネントでナビゲーションを実装します:

typescript// src/lib/components/Header.svelte
<script>
  import { page } from '$app/stores';

  const navItems = [
    { path: '/', label: 'ホーム' },
    { path: '/about', label: '会社概要' },
    { path: '/services', label: 'サービス' },
    { path: '/contact', label: 'お問い合わせ' }
  ];

  // 現在のページかどうかを判定
  $: currentPath = $page.url.pathname;
</script>

<header class="header">
  <div class="container">
    <a href="/" class="logo">
      <h1>SampleCorp</h1>
    </a>

    <nav class="nav">
      {#each navItems as item}
        <a
          href={item.path}
          class="nav-link"
          class:active={currentPath === item.path}
        >
          {item.label}
        </a>
      {/each}
    </nav>
  </div>
</header>

<style>
  .header {
    background: #ffffff;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    position: sticky;
    top: 0;
    z-index: 100;
  }

  .container {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 1rem 2rem;
  }

  .logo h1 {
    margin: 0;
    color: #2563eb;
  }

  .nav {
    display: flex;
    gap: 2rem;
  }

  .nav-link {
    text-decoration: none;
    color: #374151;
    font-weight: 500;
    transition: color 0.2s;
  }

  .nav-link:hover,
  .nav-link.active {
    color: #2563eb;
  }
</style>

サービスページで詳細な情報を表示する実装:

typescript// src/routes/services/+page.svelte
<script>
  const services = [
    {
      id: 1,
      title: 'Webアプリケーション開発',
      description: 'SvelteKitを活用した高性能なWebアプリケーションを開発します',
      features: ['レスポンシブデザイン', 'SEO最適化', 'パフォーマンス重視']
    },
    {
      id: 2,
      title: 'システム構築',
      description: '企業のデジタル変革を支援するシステムを構築します',
      features: ['クラウド対応', 'セキュリティ強化', '保守サポート']
    },
    {
      id: 3,
      title: 'コンサルティング',
      description: '技術選定から運用まで、総合的なサポートを提供します',
      features: ['技術選定', '開発プロセス改善', 'チーム育成']
    }
  ];
</script>

<svelte:head>
  <title>サービス | SampleCorp</title>
  <meta name="description" content="SampleCorpが提供するWebアプリケーション開発サービスをご紹介します" />
</svelte:head>

<section class="services">
  <h1>私たちのサービス</h1>
  <p class="intro">最新技術を活用して、お客様のビジネスを成功に導きます</p>

  <div class="service-grid">
    {#each services as service}
      <article class="service-card">
        <h2>{service.title}</h2>
        <p>{service.description}</p>

        <ul class="features">
          {#each service.features as feature}
            <li>{feature}</li>
          {/each}
        </ul>

        <a href="/contact" class="cta-button">お問い合わせ</a>
      </article>
    {/each}
  </div>
</section>

<style>
  .services {
    padding: 2rem 0;
  }

  .intro {
    font-size: 1.2rem;
    text-align: center;
    color: #6b7280;
    margin-bottom: 3rem;
  }

  .service-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
    gap: 2rem;
    margin-top: 2rem;
  }

  .service-card {
    background: #ffffff;
    border: 1px solid #e5e7eb;
    border-radius: 12px;
    padding: 2rem;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
    transition: transform 0.2s;
  }

  .service-card:hover {
    transform: translateY(-4px);
    box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
  }

  .features {
    list-style: none;
    padding: 0;
    margin: 1.5rem 0;
  }

  .features li {
    padding: 0.5rem 0;
    color: #4b5563;
  }

  .features li::before {
    content: '✓';
    color: #10b981;
    font-weight: bold;
    margin-right: 0.5rem;
  }

  .cta-button {
    display: inline-block;
    background: #2563eb;
    color: white;
    padding: 0.75rem 1.5rem;
    border-radius: 8px;
    text-decoration: none;
    font-weight: 500;
    transition: background 0.2s;
  }

  .cta-button:hover {
    background: #1d4ed8;
  }
</style>

パラメータ付き動的ルート

ブログシステムを例にした動的ルートの実装を詳しく見ていきましょう。

typescript// src/routes/blog/+page.svelte
<script>
  /** @type {import('./$types').PageData} */
  export let data;

  import { formatDate } from '$lib/utils/date.js';
</script>

<svelte:head>
  <title>ブログ | SampleCorp</title>
  <meta name="description" content="技術情報やお知らせを発信しています" />
</svelte:head>

<section class="blog-list">
  <h1>ブログ記事一覧</h1>

  <div class="articles">
    {#each data.articles as article}
      <article class="article-card">
        <h2>
          <a href="/blog/{article.slug}">{article.title}</a>
        </h2>
        <div class="article-meta">
          <time datetime={article.publishedAt}>
            {formatDate(article.publishedAt)}
          </time>
          <span class="category">{article.category}</span>
        </div>
        <p class="excerpt">{article.excerpt}</p>
        <div class="tags">
          {#each article.tags as tag}
            <span class="tag">{tag}</span>
          {/each}
        </div>
      </article>
    {/each}
  </div>
</section>

<style>
  .articles {
    display: grid;
    gap: 2rem;
    margin-top: 2rem;
  }

  .article-card {
    background: #ffffff;
    border: 1px solid #e5e7eb;
    border-radius: 8px;
    padding: 2rem;
  }

  .article-card h2 a {
    color: #1f2937;
    text-decoration: none;
    transition: color 0.2s;
  }

  .article-card h2 a:hover {
    color: #2563eb;
  }

  .article-meta {
    display: flex;
    gap: 1rem;
    margin: 1rem 0;
    font-size: 0.9rem;
    color: #6b7280;
  }

  .category {
    background: #f3f4f6;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
  }

  .tags {
    display: flex;
    gap: 0.5rem;
    margin-top: 1rem;
  }

  .tag {
    background: #dbeafe;
    color: #1e40af;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
    font-size: 0.8rem;
  }
</style>

ブログ記事一覧のサーバー側データロード処理:

typescript// src/routes/blog/+page.server.js
/** @type {import('./$types').PageServerLoad} */
export async function load() {
  const articles = await fetchAllArticles();

  return {
    articles: articles.map((article) => ({
      slug: article.slug,
      title: article.title,
      excerpt: article.excerpt,
      publishedAt: article.publishedAt,
      category: article.category,
      tags: article.tags,
    })),
  };
}

async function fetchAllArticles() {
  // 実際の実装では、データベースやCMSから取得
  return [
    {
      slug: 'sveltekit-routing-guide',
      title: 'SvelteKitルーティング完全ガイド',
      excerpt:
        'SvelteKitのルーティング機能を詳しく解説します',
      content: '記事の本文内容...',
      publishedAt: '2024-01-15T00:00:00Z',
      category: '技術',
      tags: ['SvelteKit', 'ルーティング', 'Web開発'],
    },
    {
      slug: 'performance-optimization-tips',
      title: 'Webアプリケーションパフォーマンス最適化',
      excerpt:
        '高速なWebアプリケーションを作るためのテクニック',
      content: '記事の本文内容...',
      publishedAt: '2024-01-10T00:00:00Z',
      category: 'パフォーマンス',
      tags: [
        '最適化',
        'パフォーマンス',
        'ベストプラクティス',
      ],
    },
  ];
}

個別記事ページの動的ルート実装:

typescript// src/routes/blog/[slug]/+page.server.js
import { error } from '@sveltejs/kit';

/** @type {import('./$types').PageServerLoad} */
export async function load({ params }) {
  const { slug } = params;

  try {
    const article = await fetchArticleBySlug(slug);

    if (!article) {
      throw error(404, {
        message: '記事が見つかりません',
        details: `スラッグ "${slug}" に対応する記事が存在しません`,
      });
    }

    // 関連記事も取得
    const relatedArticles = await fetchRelatedArticles(
      article.tags,
      slug
    );

    return {
      article,
      relatedArticles,
    };
  } catch (err) {
    if (err.status === 404) {
      throw err;
    }
    throw error(500, 'サーバーエラーが発生しました');
  }
}

async function fetchArticleBySlug(slug) {
  const articles = {
    'sveltekit-routing-guide': {
      slug: 'sveltekit-routing-guide',
      title: 'SvelteKitルーティング完全ガイド',
      content: `
        SvelteKitのルーティングシステムは、現代的なWebアプリケーション開発において
        非常に重要な役割を果たします...
      `,
      publishedAt: '2024-01-15T00:00:00Z',
      category: '技術',
      tags: ['SvelteKit', 'ルーティング', 'Web開発'],
      author: {
        name: '開発太郎',
        bio: 'フロントエンド開発者',
      },
    },
  };

  return articles[slug] || null;
}

async function fetchRelatedArticles(tags, currentSlug) {
  // タグに基づいて関連記事を取得
  // 実際の実装では、共通タグの多い記事を優先的に取得
  return [
    {
      slug: 'performance-optimization-tips',
      title: 'Webアプリケーションパフォーマンス最適化',
      excerpt:
        '高速なWebアプリケーションを作るためのテクニック',
    },
  ];
}

記事詳細ページのコンポーネント:

typescript// src/routes/blog/[slug]/+page.svelte
<script>
  /** @type {import('./$types').PageData} */
  export let data;

  import { formatDate } from '$lib/utils/date.js';

  $: article = data.article;
  $: relatedArticles = data.relatedArticles;
</script>

<svelte:head>
  <title>{article.title} | ブログ | SampleCorp</title>
  <meta name="description" content={article.excerpt || article.title} />
  <meta property="og:title" content={article.title} />
  <meta property="og:description" content={article.excerpt || article.title} />
  <meta property="og:type" content="article" />
  <meta property="article:published_time" content={article.publishedAt} />
  <meta property="article:author" content={article.author.name} />
  {#each article.tags as tag}
    <meta property="article:tag" content={tag} />
  {/each}
</svelte:head>

<article class="article">
  <header class="article-header">
    <h1>{article.title}</h1>
    <div class="article-meta">
      <time datetime={article.publishedAt}>
        {formatDate(article.publishedAt)}
      </time>
      <span class="category">{article.category}</span>
      <span class="author">by {article.author.name}</span>
    </div>
    <div class="tags">
      {#each article.tags as tag}
        <span class="tag">{tag}</span>
      {/each}
    </div>
  </header>

  <div class="article-content">
    {@html article.content}
  </div>

  <footer class="article-footer">
    <div class="author-bio">
      <h3>著者について</h3>
      <p><strong>{article.author.name}</strong> - {article.author.bio}</p>
    </div>
  </footer>
</article>

{#if relatedArticles.length > 0}
  <section class="related-articles">
    <h2>関連記事</h2>
    <div class="related-grid">
      {#each relatedArticles as related}
        <a href="/blog/{related.slug}" class="related-card">
          <h3>{related.title}</h3>
          <p>{related.excerpt}</p>
        </a>
      {/each}
    </div>
  </section>
{/if}

<style>
  .article {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
  }

  .article-header {
    border-bottom: 1px solid #e5e7eb;
    padding-bottom: 2rem;
    margin-bottom: 2rem;
  }

  .article-header h1 {
    font-size: 2.5rem;
    line-height: 1.2;
    margin-bottom: 1rem;
    color: #1f2937;
  }

  .article-meta {
    display: flex;
    gap: 1rem;
    margin-bottom: 1rem;
    color: #6b7280;
    font-size: 0.9rem;
  }

  .category {
    background: #f3f4f6;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
  }

  .tags {
    display: flex;
    gap: 0.5rem;
  }

  .tag {
    background: #dbeafe;
    color: #1e40af;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
    font-size: 0.8rem;
  }

  .article-content {
    line-height: 1.7;
    font-size: 1.1rem;
    color: #374151;
  }

  :global(.article-content h2) {
    margin-top: 2rem;
    margin-bottom: 1rem;
    color: #1f2937;
  }

  :global(.article-content p) {
    margin-bottom: 1rem;
  }

  .article-footer {
    border-top: 1px solid #e5e7eb;
    padding-top: 2rem;
    margin-top: 3rem;
  }

  .author-bio {
    background: #f9fafb;
    padding: 1.5rem;
    border-radius: 8px;
  }

  .related-articles {
    margin-top: 3rem;
    padding-top: 2rem;
    border-top: 2px solid #e5e7eb;
  }

  .related-grid {
    display: grid;
    gap: 1rem;
    margin-top: 1rem;
  }

  .related-card {
    display: block;
    background: #ffffff;
    border: 1px solid #e5e7eb;
    border-radius: 8px;
    padding: 1.5rem;
    text-decoration: none;
    color: inherit;
    transition: transform 0.2s;
  }

  .related-card:hover {
    transform: translateY(-2px);
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  }

  .related-card h3 {
    margin: 0 0 0.5rem 0;
    color: #1f2937;
  }

  .related-card p {
    margin: 0;
    color: #6b7280;
    font-size: 0.9rem;
  }
</style>

レイアウトとネストルーティング

複雑な Web アプリケーションでは、異なるセクションごとに異なるレイアウトが必要になることがあります。SvelteKit のネストレイアウト機能を活用した実装例をご紹介します。

管理画面セクションの専用レイアウト実装:

typescript// src/routes/admin/+layout.svelte
<script>
  /** @type {import('./$types').LayoutData} */
  export let data;

  import { page } from '$app/stores';
  import AdminSidebar from '$lib/components/AdminSidebar.svelte';
  import AdminHeader from '$lib/components/AdminHeader.svelte';

  $: currentPath = $page.url.pathname;
</script>

<div class="admin-layout">
  <AdminHeader user={data.user} />

  <div class="admin-content">
    <AdminSidebar {currentPath} />

    <main class="main-area">
      <slot />
    </main>
  </div>
</div>

<style>
  .admin-layout {
    min-height: 100vh;
    background: #f8fafc;
  }

  .admin-content {
    display: flex;
    min-height: calc(100vh - 60px);
  }

  .main-area {
    flex: 1;
    padding: 2rem;
    background: #ffffff;
    margin: 1rem;
    border-radius: 8px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
  }
</style>

管理画面用サイドバーコンポーネント:

typescript// src/lib/components/AdminSidebar.svelte
<script>
  export let currentPath;

  const menuItems = [
    { path: '/admin', label: 'ダッシュボード', icon: '📊' },
    { path: '/admin/users', label: 'ユーザー管理', icon: '👥' },
    { path: '/admin/articles', label: '記事管理', icon: '📝' },
    { path: '/admin/settings', label: '設定', icon: '⚙️' }
  ];

  $: isActive = (path) => {
    if (path === '/admin') {
      return currentPath === '/admin';
    }
    return currentPath.startsWith(path);
  };
</script>

<aside class="sidebar">
  <nav class="nav">
    {#each menuItems as item}
      <a
        href={item.path}
        class="nav-item"
        class:active={isActive(item.path)}
      >
        <span class="icon">{item.icon}</span>
        <span class="label">{item.label}</span>
      </a>
    {/each}
  </nav>
</aside>

<style>
  .sidebar {
    width: 250px;
    background: #1f2937;
    color: #ffffff;
    padding: 1rem 0;
  }

  .nav {
    display: flex;
    flex-direction: column;
  }

  .nav-item {
    display: flex;
    align-items: center;
    gap: 0.75rem;
    padding: 0.75rem 1.5rem;
    color: #d1d5db;
    text-decoration: none;
    transition: all 0.2s;
  }

  .nav-item:hover,
  .nav-item.active {
    background: #374151;
    color: #ffffff;
  }

  .nav-item.active {
    border-right: 3px solid #3b82f6;
  }

  .icon {
    font-size: 1.2rem;
  }

  .label {
    font-weight: 500;
  }
</style>

ネストしたルート構造での記事管理機能:

typescript// src/routes/admin/articles/+page.svelte
<script>
  /** @type {import('./$types').PageData} */
  export let data;

  import { formatDate } from '$lib/utils/date.js';
</script>

<svelte:head>
  <title>記事管理 | 管理画面</title>
</svelte:head>

<div class="articles-management">
  <header class="page-header">
    <h1>記事管理</h1>
    <a href="/admin/articles/new" class="btn btn-primary">新規記事作成</a>
  </header>

  <div class="filters">
    <select class="filter-select">
      <option value="">すべてのカテゴリ</option>
      <option value="tech">技術</option>
      <option value="news">お知らせ</option>
    </select>

    <select class="filter-select">
      <option value="">すべての状態</option>
      <option value="published">公開済み</option>
      <option value="draft">下書き</option>
    </select>
  </div>

  <div class="articles-table">
    <table>
      <thead>
        <tr>
          <th>タイトル</th>
          <th>カテゴリ</th>
          <th>状態</th>
          <th>更新日</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {#each data.articles as article}
          <tr>
            <td>
              <a href="/admin/articles/{article.id}" class="article-title">
                {article.title}
              </a>
            </td>
            <td>{article.category}</td>
            <td>
              <span class="status status-{article.status}">
                {article.status === 'published' ? '公開済み' : '下書き'}
              </span>
            </td>
            <td>{formatDate(article.updatedAt)}</td>
            <td>
              <div class="actions">
                <a href="/admin/articles/{article.id}/edit" class="btn btn-sm">編集</a>
                <button class="btn btn-sm btn-danger">削除</button>
              </div>
            </td>
          </tr>
        {/each}
      </tbody>
    </table>
  </div>
</div>

<style>
  .articles-management {
    padding: 0;
  }

  .page-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 2rem;
    padding-bottom: 1rem;
    border-bottom: 1px solid #e5e7eb;
  }

  .btn {
    display: inline-block;
    padding: 0.5rem 1rem;
    border: none;
    border-radius: 6px;
    text-decoration: none;
    font-size: 0.9rem;
    font-weight: 500;
    cursor: pointer;
    transition: all 0.2s;
  }

  .btn-primary {
    background: #3b82f6;
    color: white;
  }

  .btn-primary:hover {
    background: #2563eb;
  }

  .btn-sm {
    padding: 0.25rem 0.5rem;
    font-size: 0.8rem;
  }

  .btn-danger {
    background: #ef4444;
    color: white;
  }

  .btn-danger:hover {
    background: #dc2626;
  }

  .filters {
    display: flex;
    gap: 1rem;
    margin-bottom: 2rem;
  }

  .filter-select {
    padding: 0.5rem;
    border: 1px solid #d1d5db;
    border-radius: 6px;
    background: white;
  }

  .articles-table {
    overflow-x: auto;
  }

  table {
    width: 100%;
    border-collapse: collapse;
    background: white;
  }

  th, td {
    text-align: left;
    padding: 1rem;
    border-bottom: 1px solid #e5e7eb;
  }

  th {
    background: #f9fafb;
    font-weight: 600;
    color: #374151;
  }

  .article-title {
    color: #3b82f6;
    text-decoration: none;
    font-weight: 500;
  }

  .article-title:hover {
    text-decoration: underline;
  }

  .status {
    display: inline-block;
    padding: 0.25rem 0.5rem;
    border-radius: 4px;
    font-size: 0.8rem;
    font-weight: 500;
  }

  .status-published {
    background: #d1fae5;
    color: #065f46;
  }

  .status-draft {
    background: #fef3c7;
    color: #92400e;
  }

  .actions {
    display: flex;
    gap: 0.5rem;
  }
</style>

高度なルーティングテクニック

実際のプロダクションアプリケーションでは、より高度なルーティング機能が必要になることがあります。ここでは実用的なテクニックをご紹介します。

多言語対応のルーティング実装:

typescript// src/routes/[[lang]]/+layout.server.js
import { redirect } from '@sveltejs/kit';

const supportedLanguages = ['ja', 'en'];
const defaultLanguage = 'ja';

/** @type {import('./$types').LayoutServerLoad} */
export async function load({ params, request, cookies }) {
  const lang = params.lang || defaultLanguage;

  // サポートされていない言語の場合、デフォルト言語にリダイレクト
  if (params.lang && !supportedLanguages.includes(lang)) {
    throw redirect(302, '/');
  }

  // ユーザーの言語設定を記憶
  if (params.lang) {
    cookies.set('preferred-language', lang, {
      path: '/',
      maxAge: 60 * 60 * 24 * 365, // 1年間
    });
  }

  return {
    lang,
    translations: await loadTranslations(lang),
  };
}

async function loadTranslations(lang) {
  // 実際の実装では、翻訳ファイルを読み込み
  const translations = {
    ja: {
      'nav.home': 'ホーム',
      'nav.about': '会社概要',
      'nav.contact': 'お問い合わせ',
      'common.loading': '読み込み中...',
    },
    en: {
      'nav.home': 'Home',
      'nav.about': 'About',
      'nav.contact': 'Contact',
      'common.loading': 'Loading...',
    },
  };

  return (
    translations[lang] || translations[defaultLanguage]
  );
}

API 風のルーティング設計:

typescript// src/routes/api/v1/users/[id]/+server.js
import { json, error } from '@sveltejs/kit';

/** @type {import('./$types').RequestHandler} */
export async function GET({ params, url }) {
  const { id } = params;
  const include =
    url.searchParams.get('include')?.split(',') || [];

  try {
    const user = await fetchUserById(id, include);

    if (!user) {
      throw error(404, {
        code: 'USER_NOT_FOUND',
        message: 'ユーザーが見つかりません',
      });
    }

    return json({
      data: user,
      included:
        include.length > 0
          ? await fetchIncludedData(user, include)
          : undefined,
    });
  } catch (err) {
    if (err.status) throw err;

    throw error(500, {
      code: 'INTERNAL_ERROR',
      message: 'サーバーエラーが発生しました',
    });
  }
}

/** @type {import('./$types').RequestHandler} */
export async function PUT({ params, request }) {
  const { id } = params;

  try {
    const updateData = await request.json();
    const updatedUser = await updateUser(id, updateData);

    return json({
      data: updatedUser,
      message: 'ユーザー情報を更新しました',
    });
  } catch (err) {
    throw error(400, {
      code: 'INVALID_DATA',
      message: '無効なデータが送信されました',
    });
  }
}

async function fetchUserById(id, include = []) {
  // データベースからユーザー情報を取得
  const baseUser = {
    id: parseInt(id),
    name: 'サンプルユーザー',
    email: 'user@example.com',
    createdAt: '2024-01-01T00:00:00Z',
  };

  return baseUser;
}

async function fetchIncludedData(user, include) {
  const includedData = {};

  if (include.includes('posts')) {
    includedData.posts = await fetchUserPosts(user.id);
  }

  if (include.includes('profile')) {
    includedData.profile = await fetchUserProfile(user.id);
  }

  return includedData;
}

以下の図は、高度なルーティング機能の全体像を示しています。

mermaidgraph TD
    A[リクエスト] --> B{言語判定}
    B -->|ja| C[日本語コンテンツ]
    B -->|en| D[英語コンテンツ]
    B -->|未指定| E[デフォルト言語]

    C --> F[認証チェック]
    D --> F
    E --> F

    F -->|認証済み| G[保護されたルート]
    F -->|未認証| H[パブリックルート]

    G --> I[データロード]
    H --> I

    I --> J[レンダリング]
    J --> K[レスポンス]

    L[エラーハンドリング] --> M[エラーページ]

    style A fill:#e1f5fe
    style K fill:#e8f5e8
    style M fill:#ffebee

この図では、リクエストから最終的なレスポンスまでの処理フローが示されており、言語判定、認証、データロード、エラーハンドリングなどの各段階が明確に表現されています。

まとめ

SvelteKit のルーティングシステムは、ファイルベースの直感的な設計により、開発者の生産性を大幅に向上させる革新的な機能です。基本的な静的ルートから複雑な動的ルーティング、認証が必要な保護されたルートまで、様々な要件に柔軟に対応できます。

本記事では、以下の重要なポイントを詳しく解説いたしました:

ファイルベースルーティングの利点: 設定ファイル不要で、ディレクトリ構造がそのまま URL に対応する直感的なシステムです。

動的ルートの活用: ブラケット記法やキャッチオールルートにより、柔軟なパラメータ処理が可能になります。

レイアウトシステム: ネストしたレイアウト機能により、セクションごとに異なるデザインを効率的に管理できます。

認証とセキュリティ: ルートガード機能により、セキュアな Web アプリケーションを構築できます。

パフォーマンス最適化: SSR、CSR、プリレンダリングを適切に組み合わせることで、最適なユーザー体験を実現できます。

SvelteKit のルーティング機能を習得することで、より保守性が高く、スケーラブルな Web アプリケーションの開発が可能になります。実際のプロジェクトでは、要件に応じてこれらの機能を組み合わせて活用していただければと思います。

関連リンク