SvelteKitで作るフルスタック個人ブログ | 第6回: SEO最適化とVercelでのデプロイ

こんにちは！「SvelteKitで作るフルスタック個人ブログ」シリーズの第6回へようこそ。第5回では、**Lucia**を使って認証機能を追加し、ログインしたユーザーがダッシュボードから記事を作成できるようにしました。今回は、ブログを本番環境に公開するため、**SEO最適化**を行い、**Vercel**を使ってデプロイします。さらに、**Skeleton UI**のアニメーションやパフォーマンス最適化を追加し、ユーザー体験を向上させます。Next.jsのデプロイとの比較も交えながら、SvelteKitのシンプルなデプロイ体験を体感しましょう！

---

## この記事の目標
- ブログの**SEO最適化**（メタデータ、サイトマップ、静的生成）を行う。
- **Skeleton UI**のトランジションを使ってUIを滑らかにする。
- パフォーマンスを最適化（画像の遅延読み込みなど）。
- **Vercel Adapter**を使ってSvelteKitプロジェクトをVercelにデプロイする。
- Next.jsのデプロイやSEOとの違いを比較し、SvelteKitの開発体験（DX）の良さを確認する。

最終的には、SEOに優れ、パフォーマンスの高いブログを本番環境で公開し、誰でもアクセスできる状態にします。では、始めましょう！

---

## SEO最適化

SEO（検索エンジン最適化）は、ブログが検索エンジンで上位に表示されるために重要です。SvelteKitは、**メタデータ**や**静的生成（SSG）**を簡単に設定できる機能を提供します。

### 1. メタデータの設定
各ページにSEO用のメタデータを追加します。`src/routes/+layout.svelte`を更新して、ベースメタデータを設定：

```svelte
<script>
  import { AppBar, AppShell } from '@skeletonlabs/skeleton';
  export let data;
</script>

<svelte:head>
  <title>My Blog</title>
  <meta name="description" content="SvelteKitで構築された個人ブログ。技術記事やチュートリアルを公開しています。" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <meta charset="UTF-8" />
</svelte:head>

<AppShell>
  <svelte:fragment slot="header">
    <AppBar background="bg-primary-500">
      <svelte:fragment slot="lead">
        <h1 class="text-2xl font-bold text-white">My Blog</h1>
      </svelte:fragment>
      <svelte:fragment slot="trail">
        <a href="/" class="btn variant-ghost-surface">Home</a>
        <a href="/blog" class="btn variant-ghost-surface">Blog</a>
        <a href="/about" class="btn variant-ghost-surface">About</a>
        {#if data.session}
          <a href="/dashboard" class="btn variant-ghost-surface">ダッシュボード</a>
          <form method="POST" action="/logout" class="inline">
            <button type="submit" class="btn variant-ghost-error">ログアウト</button>
          </form>
        {:else}
          <a href="/login" class="btn variant-ghost-surface">ログイン</a>
        {/if}
      </svelte:fragment>
    </AppBar>
  </svelte:fragment>
  <slot />
  <svelte:fragment slot="footer">
    <footer class="bg-surface-900 text-white p-4 text-center">
      <p>© 2025 My Blog. All rights reserved.</p>
    </footer>
  </svelte:fragment>
</AppShell>
```

- **解説**：
  - `<svelte:head>`でベースの`<title>`と`<meta>`タグを設定。
  - ナビゲーションバーにログイン状態に応じたリンク（ダッシュボード、ログアウト）を追加。

ブログ一覧ページ（`src/routes/blog/+page.svelte`）に動的なメタデータを設定：

```svelte
<script>
  import { useQuery } from '@sveltestack/svelte-query';
  import { Card, CardHeader, CardContent, CardFooter, Spinner } from '@skeletonlabs/skeleton';

  const query = useQuery('posts', async () => {
    const response = await fetch('/api/posts');
    if (!response.ok) throw new Error('データの取得に失敗しました');
    return response.json();
  });
</script>

<svelte:head>
  <title>ブログ記事一覧 | My Blog</title>
  <meta name="description" content="SvelteKitで構築されたブログの記事一覧。最新の技術記事をチェック！" />
</svelte:head>

<div class="container mx-auto p-8">
  <h1 class="h1 mb-8">ブログ記事一覧</h1>
  {#if $query.isLoading}
    <div class="flex justify-center">
      <Spinner />
    </div>
  {:else if $query.isError}
    <p class="text-error-500">エラー: {$query.error.message}</p>
  {:else}
    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
      {#each $query.data as post}
        <Card>
          <CardHeader>
            <h2 class="h3">{post.title}</h2>
            <p class="text-sm text-surface-500">{post.createdAt}</p>
          </CardHeader>
          <CardContent>
            <p>{post.excerpt}</p>
          </CardContent>
          <CardFooter>
            <a href="/blog/{post.slug}" class="btn variant-filled-primary">続きを読む</a>
          </CardFooter>
        </Card>
      {/each}
    </div>
  {/if}
</div>
```

記事詳細ページ（`src/routes/blog/[slug]/+page.svelte`）も同様に：

```svelte
<script>
  import { useQuery } from '@sveltestack/svelte-query';
  import { Spinner } from '@skeletonlabs/skeleton';
  import { error } from '@sveltejs/kit';

  export let data;

  const query = useQuery(['post', data.slug], async () => {
    const response = await fetch(`/api/posts/${data.slug}`);
    if (!response.ok) throw error(404, '記事が見つかりません');
    return response.json();
  });
</script>

<svelte:head>
  {#if !$query.isLoading && !$query.isError}
    <title>{$query.data.title} | My Blog</title>
    <meta name="description" content={$query.data.excerpt} />
  {/if}
</svelte:head>

<div class="container mx-auto p-8">
  {#if $query.isLoading}
    <div class="flex justify-center">
      <Spinner />
    </div>
  {:else if $query.isError}
    <p class="text-error-500">エラー: {$query.error.message}</p>
  {:else}
    <article class="card p-6 bg-surface-50">
      <header class="mb-4">
        <h1 class="h2">{$query.data.title}</h1>
        <p class="text-sm text-surface-500">{$query.data.createdAt}</p>
      </header>
      <section class="prose">
        <p>{$query.data.content}</p>
      </section>
      <footer class="mt-6">
        <a href="/blog" class="btn variant-ghost-primary">一覧に戻る</a>
      </footer>
    </article>
  {/if}
</div>
```

- **解説**：
  - 詳細ページでは、データがロードされた後に動的に`<title>`と`<meta>`を設定。
  - `$query.data.excerpt`をメタ記述に使用し、SEO効果を高める。

### 2. サイトマップの生成
サイトマップは、検索エンジンがブログの構造を理解するのに役立ちます。`src/routes/sitemap.xml/+server.ts`を作成：

```ts
import { db } from '$lib/db';
import { posts } from '$lib/db/schema';

export async function GET() {
  const allPosts = await db.select().from(posts).all();
  const baseUrl = 'https://your-blog.vercel.app';

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>${baseUrl}</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>
  <url>
    <loc>${baseUrl}/blog</loc>
    <lastmod>${new Date().toISOString()}</lastmod>
    <changefreq>daily</changefreq>
    <priority>0.8</priority>
  </url>
  ${allPosts
    .map(
      (post) => `
  <url>
    <loc>${baseUrl}/blog/${post.slug}</loc>
    <lastmod>${post.createdAt}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.6</priority>
  </url>`
    )
    .join('')}
</urlset>`;

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml'
    }
  });
}
```

- **解説**：
  - データベースから全記事を取得し、動的にサイトマップを生成。
  - ホームページ、ブログ一覧、個別記事をリストアップ。

---

## パフォーマンス最適化

### 1. Skeleton UIのトランジション
記事一覧ページにスムーズなアニメーションを追加します。`src/routes/blog/+page.svelte`を更新：

```svelte
<script>
  import { useQuery } from '@sveltestack/svelte-query';
  import { Card, CardHeader, CardContent, CardFooter, Spinner } from '@skeletonlabs/skeleton';
  import { fade } from 'svelte/transition';

  const query = useQuery('posts', async () => {
    const response = await fetch('/api/posts');
    if (!response.ok) throw new Error('データの取得に失敗しました');
    return response.json();
  });
</script>

<svelte:head>
  <title>ブログ記事一覧 | My Blog</title>
  <meta name="description" content="SvelteKitで構築されたブログの記事一覧。最新の技術記事をチェック！" />
</svelte:head>

<div class="container mx-auto p-8">
  <h1 class="h1 mb-8">ブログ記事一覧</h1>
  {#if $query.isLoading}
    <div class="flex justify-center">
      <Spinner />
    </div>
  {:else if $query.isError}
    <p class="text-error-500">エラー: {$query.error.message}</p>
  {:else}
    <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
      {#each $query.data as post (post.slug)}
        <div transition:fade={{ duration: 300 }}>
          <Card>
            <CardHeader>
              <h2 class="h3">{post.title}</h2>
              <p class="text-sm text-surface-500">{post.createdAt}</p>
            </CardHeader>
            <CardContent>
              <p>{post.excerpt}</p>
            </CardContent>
            <CardFooter>
              <a href="/blog/{post.slug}" class="btn variant-filled-primary">続きを読む</a>
            </CardFooter>
          </Card>
        </div>
      {/each}
    </div>
  {/if}
</div>
```

- **解説**：
  - Svelteの`transition:fade`をカードに適用し、記事が表示される際に滑らかなフェードイン効果を追加。
  - `{#each ... (post.slug)}`で一意なキーを指定し、トランジションを正しく動作させる。

### 2. 画像の遅延読み込み
ブログに画像を追加する場合、遅延読み込み（lazy loading）を設定します。記事詳細ページで仮に画像を表示する例：

```svelte
<img src="https://via.placeholder.com/800x400" alt={$query.data.title} loading="lazy" class="w-full mb-4" />
```

- `loading="lazy"`はブラウザのネイティブ機能で、画像がビューポートに入るまで読み込みを遅らせます。

### 3. Next.jsとの比較
Next.jsでは、SEOとパフォーマンス最適化のために以下のような機能を使います：
- `<Head>`コンポーネントでメタデータ設定。
- `next/image`で画像最適化。
- `getStaticProps`で静的生成。

Next.jsの`next/image`は強力ですが、設定が複雑で、外部画像には制限があります。一方、SvelteKitは`<svelte:head>`でメタデータがシンプルに設定でき、静的生成も`export const prerender = true`で簡単に有効化できます。Svelteのトランジションは、Reactのアニメーションライブラリ（例：Framer Motion）に比べ、軽量で簡単に適用可能です。

---

## Vercelでのデプロイ

### 1. Vercel Adapterのインストール
SvelteKitをVercelに最適化するため、Vercel Adapterをインストール：

```bash
npm install @sveltejs/adapter-vercel
```

`svelte.config.js`を更新：

```js
import adapter from '@sveltejs/adapter-vercel';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

/** @type {import('@sveltejs/kit').Config} */
const config = {
  preprocess: vitePreprocess(),
  kit: {
    adapter: adapter()
  }
};

export default config;
```

### 2. 静的生成の有効化
ブログページを静的生成（SSG）にするため、`src/routes/blog/+page.ts`を作成：

```ts
export const prerender = true;
```

記事詳細ページも同様に、`src/routes/blog/[slug]/+page.ts`：

```ts
import { db } from '$lib/db';
import { posts } from '$lib/db/schema';

export const prerender = true;

export async function entries() {
  const allPosts = await db.select().from(posts).all();
  return allPosts.map((post) => ({ slug: post.slug }));
}

export function load({ params }) {
  return {
    slug: params.slug
  };
}
```

- **解説**：
  - `prerender = true`で静的生成を有効化。
  - `entries`関数で全記事の`slug`を返し、ビルド時に各ページを生成。

### 3. Vercelにデプロイ
以下の手順でデプロイ：
1. プロジェクトをGitHubリポジトリにプッシュ。
2. [Vercelダッシュボード](https://vercel.com/)で新しいプロジェクトを作成。
3. GitHubリポジトリを接続し、デプロイ設定を以下のように：
   - Framework Preset: `SvelteKit`
   - Root Directory: （デフォルト）
   - Build Command: `npm run build`
   - Output Directory: `.svelte-kit`
4. 環境変数（例：`NODE_ENV=production`）を必要に応じて設定。
5. 「Deploy」ボタンをクリック。

デプロイ後、Vercelが提供するURL（例：`https://your-blog.vercel.app`）でブログが公開されます。

### 4. Vercel Analyticsの有効化
Vercelダッシュボードで「Analytics」を有効化し、ブログのトラフィックやパフォーマンスを監視します。コードの変更は不要で、すぐにデータが確認できます。

---

## 動作確認

デプロイ後、以下のURLを確認：
- `https://your-blog.vercel.app/`：ホームページ。
- `https://your-blog.vercel.app/blog`：静的生成された記事一覧。
- `https://your-blog.vercel.app/blog/your-post-slug`：個別記事。
- `https://{your-blog.vercel.app}/sitemap.xml`：サイトマップ。

**Skeleton UI**のトランジションにより、記事一覧が滑らかに表示され、SEOメタデータが検索エンジンに正しく認識されます。Vercelの自動スケーリングにより、高トラフィックでも安定して動作します。

---

## やってみよう！（チャレンジ）
ブログにカスタムドメインを追加してみましょう！Vercelダッシュボードの「Domains」セクションで、購入済みのドメイン（例：`myblog.com`）を追加し、DNS設定を行います。また、Svelteの`transition:slide`や`transition:scale`を他のページ（例：ダッシュボードやログインフォーム）に適用して、UIをさらに滑らかにしてみてください。以下の例を参考に：

```svelte
<div transition:slide={{ duration: 300 }}>
  <Card>...</Card>
</div>
```

Svelteのトランジションは軽量で、簡単にカスタマイズできるので、さまざまな効果を試してみましょう！

---

## まとめ
この記事では、**SEO最適化**（メタデータ、サイトマップ、静的生成）を行い、**Skeleton UI**のトランジションでUIを強化しました。さらに、**Vercel Adapter**を使ってブログをVercelにデプロイし、本番環境で公開しました。Next.jsと比べ、SvelteKitのSEO設定はシンプルで、Vercelとの統合もスムーズです。Svelteの軽量なトランジションは、Reactのアニメーションライブラリより簡単に適用でき、DXの良さを実感できたと思います。

次回は、リアルタイム機能（例：コメント）やテストの追加など、ブログをさらに進化させる方法を解説します。お楽しみに！

この記事が役に立ったと思ったら、**LGTM**や**ストック**していただけると励みになります！質問や改善アイデアがあれば、コメントで教えてください。次の記事でまたお会いしましょう！