SvelteKit チュートリアル - 記事投稿サイトを作ってみよう
SvelteKit は Svelte と Vite で構築たフレームワークです。SvelteKit は Web アプリケーションを開発するために必要な機能を提供します。この記事では、SvelteKit を使用して記事投稿サイトを作成するチュートリアルを紹介します。記事投稿サイトは、記事の一覧表示、記事の詳細表示、記事の投稿、記事の削除の機能を持つシンプルな Web アプリケーションです。
SvelteKit は、Svelte と Vite で構築されたフレームワークです。SvelteKit と Svelte の関係は、Next.js と React、Nuxt.js と Vue.js の関係に似ています。SvelteKit は Web アプリケーションを開発するために必要な以下の機能を提供します。
- ルーティング
- 柔軟なレンダリング方式(SSR, prerendering, ISR...)
- ビルド最適化
- 様々なプラットフォームへのデプロイをサポートするアダプター
- プリフェッチ
- 画像最適化
この記事では、SvelteKit を使用して記事投稿サイト「SvelteDiary」を作成するチュートリアルを紹介します。記事投稿サイトは、記事の一覧表示、記事の詳細表示、記事の投稿、記事の編集、記事の削除の機能を持つシンプルな Web アプリケーションです。
最終的なコードは以下のリポジトリで確認できます。
プロジェクトを作成する
まずは SvelteKit のプロジェクトを作成します。以下のコマンドを実行しましょう。
npm create svelte@latest
対話形式でプロジェクトの設定を行います。ここでは以下の設定を選択します。
Welcome to SvelteKit!
│
◇ Where should we create your project?
│ sveltekit-tutorial
│
◇ Which Svelte app template?
│ Skeleton project
│
◇ Add type checking with TypeScript?
│ Yes, using TypeScript syntax
│
◆ Select additional options (use arrow keys/space bar)
│ ◼ Add ESLint for code linting
│ ◼ Add Prettier for code formatting
│ ◻ Add Playwright for browser testing
│ ◻ Add Vitest for unit testing
│ ◼ Try the Svelte 5 preview (unstable!)
└
この記事では 2024 年 5 月時点では不安定バージョンである Svelte 5 を使用しています。Svelte 5 を使用した場合の API はまだドキュメントには記載されておらず、変更される可能性があります。
プロジェクトが作成されたら、作成したプロジェクトに移動して以下のコマンドを実行し開発サーバーを起動します。
cd sveltekit-tutorial
npm install
npm run dev
http://localhost:5173 にアクセスすると、SvelteKit のデフォルトのページが表示されます。
SvelteKit はファイベースのルーティングを採用しています。ルーティングのパスと src/routes
ディレクトリ内のファイルが対応しており、+page.svelte
ファイルがアプリケーションのページとて表示されます。つまり、src/routes/+page.svelte
が /
に対応し、src/routes/+about.svelte
が /about
に対応します。
http://localhost:5173 で表示されている画面に対応するファイルは src/routes/+page.svelte
です。試しにこのファイルを編集して、表示される内容を変更してみましょう。
<h1>Svelte Diary</h1>
HMR(Hot Module Replacement)により、ファイルを保存すると自動的にブラウザがリロードされ、変更が反映されます。
Tailwind CSS を導入する
今回はスタイリングのために Tailwind CSS を使用します。Tailwind CSS は、Utility-first CSS フレームワークであり、mr-2
や bg-blue-500
などのユーティリティクラスを組み合わせてスタイリングを行います。
次のコマンドを実行して、Tailwind CSS をインストールします。
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
tailwind.cofing.js
ファイルが作成されるので、以下のように設定します。
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: {
extend: {}
},
plugins: []
};
src/app.css
ファイルを作成し、Tailwind CSS を読み込みます。
@tailwind base;
@tailwind components;
@tailwind utilities;
src/app.css
はすべてのページで読み込まれる CSS ファイルです。SvelteKit ではすべてのページで表示されるべき要素を描画するために、特別なファイル +layout.svelte
を使用します。+layout.svelte
は src/routes
ディレクトリ内に作成します。
<script lang="ts">
const { children } = $props();
import "../app.css";
</script>
{@render children()}
$props()
はコンポーネントの Props を取得する関数です。children
は予約された Props で、子コンポーネントを描画するために使用します。この children
Props には各ページのコンポーネントが渡されます。children
は Snippet
という型であり、これは {@render}
を使って描画できます。
ともあれ app.css
を読み込んだことで、すべてのページで Tailwind CSS が適用されるようになりました。試しに src/routes/+page.svelte
を以下のように編集してみましょう。
<h1 class="text-red-500 text-4xl font-bold text-center mt-10">Svelte Diary</h1>
再度 npm run dev
を実行して、ブラウザで表示を確認してみましょう。文字が赤色になっていることが確認できるはずです。
トップページを作成する
はじめにアプリケーションのトップページを作成していきましょう。まずはすべてのページで共通して表示されるナビゲーションバーとフッターを作成します。src/routes/+layout.svelte
を以下のように編集します。
<script lang="ts">
const { children } = $props();
import '../app.css';
</script>
<div class="bg-gray-200 min-h-screen flex flex-col">
<header class="sticky top-0 z-50 bg-white shadow-md h-16">
<nav class="container mx-auto flex justify-between items-center py-4">
<a href="/" class="text-xl font-bold">Svelte Diary</a>
<ul class="flex gap-4">
<li>
<a href="/articles" class="text-gray-800">記事の一覧</a>
</li>
<li>
<a href="/articles/new" class="text-gray-800">記事の投稿</a>
</li>
</ul>
</nav>
</header>
<main class="container mx-auto mt-4 flex-grow">
{@render children()}
</main>
<footer class="bg-gray-800 text-white text-center py-4 h-16">
<p>© 2024 Svelte Diary</p>
</footer>
</div>
ナビゲーションバーでは記事の一覧画面と記事の投稿画面へのリンクを表示しています。なお SvelteKit では SPA 遷移を行うために特別なコンポーネントは使用せずに、単に a
タグを使用できます。
続いてトップページを作成しましょう。src/routes/+page.svelte
を編集します。
<script lang="ts">
// ページのレンダリング方式を指定
// prerender: true は、事前レンダリングを有効にする
export const prerender = true;
</script>
<!-- メタタグを設定 -->
<svelte:head>
<title>Svelte Diary</title>
<meta
name="description"
content="Svelte Diary は、ユーザーが日々の思いや出来事をシンプルで使いやすいインターフェースで記録し、共有するための記事投稿サービスです。"
/>
</svelte:head>
<div class="py-12 px-12 mt-8 text-center bg-white max-w-screen-xl mx-auto">
<h1 class="mb-4 text-6xl font-extrabold tracking-tight leading-none text-gray-90">
Svelte Diary
</h1>
<p class="mb-8 text-lg font-normal text-gray-500 px-48">
Svelte Diary
は、ユーザーが日々の思いや出来事をシンプルで使いやすいインターフェースで記録し、共有するための記事投稿サービスです。直感的なデザインとスムーズな操作性により、ユーザーは簡単に自分の日常を記録し、思い出を保存することができます。
</p>
<div class="flex flex-row justify-center sm:space-y-0 sm:space-x-4">
<a
href="/articles"
class="inline-flex justify-center items-center py-3 px-5 text-base font-medium text-center text-gray-900 rounded-lg border border-gray-300 hover:bg-gray-100 focus:ring-4 focus:ring-gray-100"
>
記事を読む
</a>
<a
href="/articles/new"
class="inline-flex justify-center items-center py-3 px-5 text-base font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-primary-300"
>
記事を投稿
</a>
</div>
</div>
SvelteKit はデフォルトではサーバーサイドレンダリングを行い、クライアントに HTML を返します。その後クライアント側で再レンダリングを行いハイドレーションを実行することで、インタラクティブを有効にしています。ですが、一般的なアプリケーションではすべてのページでサーバーサイドレンダリングを行うことが望ましいとは限りません。
例えば、記事の一覧画面では毎回データベースから記事を取得して表示する必要があるため、サーバーサイドでレンダリングを行う必要があるでしょう。一方で、トップページのような静的なコンテンツは事前レンダリングを行い、静的な HTML ファイルとして配信することで パフォーマンスを向上させることができます。
SvelteKit ではページごとにレンダリング方式を指定できます。ここでは export const prerender = true;
とすることで、トップページの事前レンダリングを有効にしています。
このアプリケーションではトップページのみを事前レンダリングとして指定していますが、大半のページで事前レンダリングを行う場合には、+layout.svelte
に export const prerender = true;
を指定することで、すべてのページで事前レンダリングを有効にできます。その場合、サーバーサイドレンダリングを行いたいページでは export const prerender = false;
で事前レンダリングを無効にすることになります。
ページのメタタグは <svelte:head>
タグ内に記述します。<svelte:head>
要素を使うと document.head
内に要素を挿入できます。
<svelte:head>
<title>Svelte Diary</title>
<meta
name="description"
content="Svelte Diary は、ユーザーが日々の思いや出来事をシンプルで使いやすいインターフェースで記録し、共有するための記事投稿サービスです。"
/>
</svelte:head>
ここまでの実装で、トップページが完成しました。ブラウザで表示を確認してみましょう。
データベースのセットアップ
次の画面を作成する前に、ユーザーが投稿した記事を保存できるようにするためにデータベースをセットアップします。ここでは PostgreSQL を使用します。Docker を使用して PostgreSQL を起動するので、Docker がインストールされていることを確認してください。
docker --version
Docker version 24.0.5
PostgreSQL の起動
docker-compose.yml
ファイルを作成し、以下の内容を記述します。
version: '3.8'
services:
db:
image: postgres:14
container_name: postgres
ports:
- 5432:5432
volumes:
- db-store:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_USER=${POSTGRES_USER}
- POSTGRES_DB=${POSTGRES_DB}
volumes:
db-store:
POSTGRES_PASSWORD
、POSTGRES_USER
、POSTGRES_DB
は環境変数として設定します。.env
ファイルを作成し、以下の内容を記述します。
POSTGRES_PASSWORD=password
POSTGRES_USER=postgres
POSTGRES_DB=svelte_diary
以下のコマンドで PostgreSQL を起動します。
docker compose up -d
Prisma のセットアップ
続いてアプリケーションから PostgreSQL に接続するためのライブラリである Prisma をインストールします。Prisma は TypeScript で型安全なクエリを記述できる ORM ライブラリです。以下のコマンドで Prisma をインストールします。
npm install @prisma/client
npm install prisma tsx --save-dev
npx prisma init
npx prisma init
コマンドを実行すると、prisma
ディレクトリが作成され、schema.prisma
ファイルが作成されます。また .env
ファイルに DATABASE_URL
が追加されているので、以下のように書き換えます。
DATABASE_URL="postgresql://postgres:password@localhost:5432/svelte_diary?schema=public"
続いてテーブルを作成するためのスキーマを schema.prisma
ファイルに記述します。
model Article {
id Int @id @default(autoincrement())
title String
content String
createdAt DateTime @default(now())
}
Article
モデルは記事を表すモデルで、id
、title
、content
、createdAt
のフィールドを持ちます。id
は記事の一意な ID で、title
は記事のタイトル、content
は記事の本文、createdAt
は記事の作成日時を表します。
schema.prisma
ファイルの編集が完了したら、以下のコマンドでマイグレーションを実行します。
npx prisma migrate dev --name init
マイグレーションに成功すると、prisma
ディレクトリ内に migrations
ディレクトリが作成されます。YYYYMMDDHHMMSS-init
という名前のディレクトリが作成され、その中に migration.sql
ファイルが作成されます。このファイルにはデータベースのスキーマ変更を行う SQL 文が記述されています。
-- CreateTable
CREATE TABLE "Article" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Article_pkey" PRIMARY KEY ("id")
);
テストデータの追加
これでデータベースのセットアップが完了しました。最後にテスト用のデータを追加する script を作成します。scripts
ディレクトリを作成し、seed.ts
ファイルを作成します。
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.article.create({
data: {
title: '今日の散歩',
content: '朝、気持ちの良い風が吹いていたので、近くの公園に散歩に行きました。色とりどりの花々が咲き誇り、小さな鳥たちが忙しく飛び交っていました。自然の中でリフレッシュできる時間は本当に素晴らしいです。',
},
});
await prisma.article.create({
data: {
title: '今日のランチ',
content: '昼食は、近くのカフェでサンドイッチとコーヒーをいただきました。サンドイッチはハムとチーズがたっぷり挟まれており、コーヒーは香り高くて美味しかったです。美味しい食事をいただくと、気持ちもリフレッシュされますね。',
},
});
await prisma.article.create({
data: {
title: '夜は読書を楽しむ',
content: '夜、家に帰ってからは、最近読み始めた小説を読み進めました。物語の展開が気になって、ついつい夜更かししてしまいました。読書は、日常の中で自分だけの時間を楽しむことができるので、とても大切な時間です。',
},
});
}
main()
.catch((e) => {
console.error(e);
})
.finally(async () => {
await prisma.$disconnect();
});
prisma.article.create
メソッドを使用して記事を追加しています。finally
ブロックで prisma.$disconnect()
メソッドを使用して確実にデータベースとの接続が切断されるようにしています。
package.json
ファイルにテストデータを追加するスクリプトを実行する seed
コマンドを追加します。
{
"scripts": {
"seed": "tsx scripts/seed.ts"
}
}
seed
コマンドを実行してテストデータを追加しましょう。
npm run seed
データが正しく追加されたか確認するために、Prisma Studio を起動します。Prisma Studio はデータベースの内容を確認するための GUI ツールです。
npx prisma studio
http://localhost:5555 にアクセスすると、Prisma Studio が起動します。Article
テーブルを選択して、追加した記事が表示されていることを確認します。
記事の一覧表示
次に記事の一覧表示画面を作成します。まずは src/routes/articles/+pages.svelte
を作成して /articles
にアクセスしたときに表示されるページを作成します。
<h1 class="text-3xl font-bold mt-4">記事一覧</h1>
http://localhost:5173/articles にアクセスし、記事一覧
と表示されることを確認します。
記事の一覧を取得する
次に、データベースから記事の一覧を取得して表示するための関数を追加します。Prisma を通じてデータベースを操作するためには、PrismaClient
を初期化する必要があります。src/lib/server/prisma.ts
ファイルを作成し、以下の内容を記述します。
import { dev } from '$app/environment';
import { PrismaClient } from '@prisma/client';
declare const global: {
prisma: PrismaClient;
};
let prisma: PrismaClient;
if (dev) {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
} else {
prisma = new PrismaClient();
}
export default prisma;
SvelteKit により提供されている $app/environment
モジュールを使用して、開発環境かどうかを判定しています。開発サーバーとして実行されている場合には、HMR によりモジュールが再読み込みされ複数回クライアントがデータベースに接続されることを防ぐために、global
オブジェクトを使用して PrismaClient のインスタンスを共有しています。
なお src/lib/server
ディレクトリ配下に配置されたファイルは Server-only Modules として扱われます。Server-only Modules としてマークされたファイルは、クライアント側で import しようとした場合エラーが発生します。
[vite] Internal server error: Cannot import $lib/server/prisma.ts into client-side code
これにより、サーバー側でのみ扱うべき機密情報が誤ってクライアント側に漏洩することを防ぐことができます。
同様に記事の一覧を取得する関数を src/lib/server/articles.ts
ファイルに追加しましょう。
import prisma from './prisma';
import type { Article } from '@prisma/client';
// db クライアントを隠蔽するために re-export する
export type { Article };
export async function getArticles(): Promise<{ articles: readonly Article[] }> {
const articles = await prisma.article.findMany({
orderBy: {
createdAt: 'desc'
}
});
return { articles };
}
prisma.article.findMany
メソッドを使用して、データベースからすべての記事を取得しています。orderBy
オプションを使用して、createdAt
フィールドを降順でソートします。これで DB から記事を取得するための準備が整いました。
記事一覧を表示する
/articles
にアクセスしたときに記事一覧をデータベースから取得して表示するようにしましょう。データベースにアクセスする処理は必ずサーバーサイドで実行する必要があります。+page.svelte
ファイルはクライアント側で実行されるため、ここでは getArticles
関数を呼び出すことができません。
SvelteKit においてサーバー側でコンポーネントが描画される前にデータを取得するために、対応するページコンポーネントの隣に page.server.ts
ファイルを作成します。page.server.ts
ファイルにおいて load
関数をエクスポートすることで、サーバーサイドでデータを取得し、クライアントに渡すことができます。
src/routes/articles/+page.server.ts
ファイルを作成し、以下の内容を記述します。
import { getArticles } from '$lib/server/articles';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const { articles } = await getArticles();
return { articles };
};
load()
関数には PageServerLoad
型を指定しています。この PageServerLoad
型は SvelteKit により自動で生成される型であり、load()
関数を実装を変更するたびに、返却したオブジェクトに適した型が生成されます。SvelteKit はこの自動的な型生成により、サーバーとクライアント間のデータの受け渡しの型安全性を保証します。
次に +page.svelte
ファイルを編集して、load()
関数から返されるデータを受け取るようにしましょう。
<script lang="ts">
const { data } = $props();
</script>
<h1 class="text-3xl font-bold mt-4">記事一覧</h1>
<ul class="mt-4">
{#each data.articles as article (article.id)}
<li>
{article.title}
</li>
{/each}
</ul>
load()
関数から返されるデータは data
Props としてコンポーネントに渡されます。渡されたデータは Article
型の配列であり、#each
ディレクティブを使用して記事の一覧をリストとして表示しています。#each
ディレクティブはリストの反復処理を行うために使用され、{#each data.articles as article}
で data.articles
配列の各要素を article
として取り出しています。また #each{}
の最後の ()
では article.id
を一意な key として指定しています。
http://localhost:5173/articles にアクセスし、データベースから取得した記事の一覧が表示されることを確認します。
カードコンポーネントを作成する
記事のタイトルをただ表示するだけでは殺風景ですので、カードコンポーネントを作成してスタイリングを行いましょう。src/routes/articles/Card.svelte
ファイルを作成します。
SvelteKit では routes/
ディレクトリ配下にあるファイルは +page.svelte
のみがページコンポーネントとして扱われルーティングの対象となります。そのため、Card.svelte
のようなファイルを routes/
ディレクトリ配下に自由に配置できます。このように、ある特定の場所でのみで使用するコンポーネントをまとめた場所に配置することは「コロケーション」と呼ばれ、コードのメンテナンス性を向上させるための手法の 1 つです。
<script lang="ts">
type Props = {
id: number;
title: string;
createdAt: Date;
};
const { id, title, createdAt }: Props = $props();
</script>
<div
class="bg-white shadow-md rounded-lg p-4 hover:shadow-lg transition duration-300 ease-in-out flex"
>
<div>
<a href="/articles/{id}" class="hover:underline">
<h2 class="text-lg font-bold">{title}</h2>
</a>
<time class="text-sm text-gray-500" datetime={createdAt.toISOString()}>
{createdAt.toLocaleDateString()}
</time>
</div>
</div>
$props()
関数を使用して記事のデータを受け取ります。オブジェクトに対して型注釈を付けることで、Props の型を明示的に指定できます。記事のタイトルにはリンクを設定して記事の詳細画面へ遷移できるようにしています。
+page.svelte
ファイルを編集して、カードコンポーネントを使用して記事の一覧を表示するようにしましょう。
<script lang="ts">
import Card from './Card.svelte';
const { data } = $props();
</script>
<h1 class="text-3xl font-bold mt-4">記事一覧</h1>
<ul class="mt-4 grid grid-cols-1 gap-4">
{#each data.articles as article (article.id)}
<Card id={article.id} title={article.title} createdAt={article.createdAt} />
{/each}
</ul>
ここまでの実装を確認してみましょう。http://localhost:5173/articles にアクセスすると、カード UI で記事の一覧が表示されることが確認できます。
記事の詳細画面
次に記事の詳細表示画面を作成します。/articles/:id
にアクセスしたときに、指定された ID の記事をデータベースから取得して表示します。例として /articles/1
にアクセスしたときには、ID が 1 の記事の詳細が表示されます。
SvelteKit でダイナミックなルートを扱うためには、src/routes/articles/[id]/+page.svelte
のようにディレクトリ名を []
で囲みます。[]
で囲った部分は動的なルートパラメータであることを示します。
まずは src/routes/articles/[id]/+page.svelte
ファイルを作成しましょう。
<h1 class="text-3xl font-bold mt-4">記事詳細</h1>
http://localhost:5173/articles/1 にアクセスし、記事詳細
と表示されることを確認します。
記事の詳細を取得する
まずは指定された ID の記事をデータベースから取得するための関数を追加します。src/lib/server/articles.ts
ファイルに以下の関数を追加しましょう。
export async function getArticleById(id: number): Promise<Article | null> {
const article = await prisma.article.findUnique({
where: {
id
}
});
return article;
}
where
句の条件に id
を指定して、指定された ID の記事が取得できるようにしています。記事が見つからない場合には null
を返すようにしています。
src/routes/articles/[id]/+page.server.ts
ファイルを作成し、サーバーサイドでデータを取得するための load
関数を追加します。
import { getArticleById } from '$lib/server/articles';
import { error } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const article = await getArticleById(Number(params.id));
if (article === null) {
error(404, {
message: `Article with id ${params.id} not found`
});
}
return { article };
};
パスパラメータは load
関数の引数の params
から取得できます。これによりパスパラメータの ID を使用して getArticleById
関数を呼び出し、指定された ID の記事を取得しています。記事が見つからない場合には、error
関数を使用して 404 エラーを返します。error
は never
型を返す関数であるため、if
の後のブロックでは article
変数の型が Article
であることが保証されています。
それでは、+page.svelte
ファイルを編集して、取得した記事のデータを表示するようにしましょう。load()
関数で返した値は $props()
で受け取るのでした。
<script lang="ts">
const { data } = $props();
</script>
<svelte:head>
<title>{data.article.title}</title>
<meta name="description" content={data.article.content} />
</svelte:head>
<div class="mx-auto my-5 max-w-5xl">
<a href="/articles" class="flex items-center text-opacity-80 hover:underline"> ← 記事一覧に戻る</a>
</div>
<article class="p-4 mx-auto max-w-3xl">
<div class="text-center">
<h1 class="mt-4 text-2xl font-bold md:text-4xl">
{data.article.title}
</h1>
<time class="text-sm text-gray-500 block mt-2" datetime={data.article.createdAt.toISOString()}>
{data.article.createdAt.toLocaleDateString()}
</time>
</div>
<div class="bg-white py-4 px-8 mt-8 shadow-md rounded-lg">
{data.article.content}
</div>
</article>
http://localhost:5173/articles/1 にアクセスし、指定された ID の記事の詳細が表示されることを確認します。
エラーページの作成
+page.server.ts
ファイルでは記事が見つからないときに 404 エラーを返していました。error
関数が実行された場合、デフォルトでは SvelteKit で用意されているエラー画面が表示されます。例として http://localhost:5173/articles/42 のように存在しない記事の ID を指定してアクセスしてみましょう。
デフォルトのエラー画面では、error
関数に渡したステータスコードと message
プロパティの内容が表示されています。このエラーページをカスタマイズするためには、src/routes
ディレクトリ配下に +error.svelte
ファイルを作成します。+error.svelte
ファイルはルートごと追加できます。あるルートでエラーが発生した場合、そのルートに最も近い祖先の +error.svelte
ファイルが使用されます。
ここでは src/routes/+error.svelte
ファイルを作成し、一般的な 404 エラーページを作成します。
<script lang="ts">
import { page } from '$app/stores';
</script>
<div class="mt-4 flex flex-col items-center">
<h1 class="text-6xl font-bold italic tracking-wide">
{#if $page.status === 404}
404 Not Found
{:else}
500 Internal Server Error
{/if}
</h1>
<p
class="mb-6 mt-4 max-w-2xl font-light leading-relaxed text-gray-500 md:text-lg lg:mb-8 lg:text-xl"
>
{#if $page.status === 404}
お探しのページが見つかりませんでした。
{:else}
予期せぬエラーが発生しました。
{/if}
</p>
<div class="mt-16">
<a href="/" class="flex items-center text-opacity-80 hover:underline">トップに戻る</a>
</div>
</div>
発生したエラーの情報は page
ストアを通じてアクセスできます。Svelte のストア の値を参照するための糖衣構文として、変数名の先頭に $
を付ける構文があります。この構文は以下のように .subscribe()
メソッドを呼び出すことと同じ意味を持ちます。
import { page } from "$app/stores";
let $page;
const unsubscribe = page.subscribe((value) => {
page = value;
});
$page.status
は現在のページのステータスコードを取得します。{#if}
条件分岐によって、ステータスコードが 404 の場合には 404 Not Found
、それ以外の場合には 500 Internal Server Error
と表示します。
{#if $page.status === 404}
404 Not Found
{:else}
500 Internal Server Error
{/if}
http://localhost:5173/articles/42 にアクセスし、カスタムの 404 エラーページが表示されることを確認します。
記事の投稿
次に記事の投稿画面を作成します。/articles/new
にアクセスしたときに、新しい記事を投稿するためのフォームを表示します。src/routes/articles/new/+page.svelte
ファイルを作成しましょう。
<svelte:head>
<title>新規記事投稿</title>
</svelte:head>
<form method="POST">
<div class="grid gap-6 mt-6">
<div>
<label for="title" class="block mb-2 text-sm font-medium text-gray-900">タイトル</label>
<input
id="title"
name="title"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
/>
</div>
<div>
<label for="content" class="block mb-2 text-sm font-medium text-gray-900">本文</label>
<textarea
id="content"
name="content"
rows="10"
class="border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
></textarea>
</div>
<div>
<button
type="submit"
class="inline-flex justify-center items-center py-3 px-5 text-base font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-primary-300"
>
投稿する
</button>
</div>
</div>
</form>
<style>
/* field-sizing は TailwindCSS でサポートしていないスタイルなので、ここで定義している */
textarea {
min-height: 4lh;
field-sizing: content;
}
</style>
SvelteKit のフォームは JavaScript を使わずに、HTML の標準的なフォーム要素を使用して作成できます。ここでは <form>
要素を用いたフォームを作成しています。
http://localhost:5173/articles/new にアクセスし、フォームが表示されることを確認します。
フォームの送信処理
フォームからポストされたデータを処理するサーバーサイドの処理を実装します。+page.server.ts
ファイルで actions
オブジェクトをエクスポートし、default
プロパティにフォームの送信処理を記述します。
import type { Actions } from './$types';
export const actions: Actions = {
default: async (event) => {
// フォームの処理を書く
}
};
フォームデータを受け取り、DB に保存する処理を追加しましょう。まずは lib/server/articles.ts
ファイルに記事を作成する関数を追加します。
export async function createArticle(data: Pick<Article, 'title' | 'content'>): Promise<Article> {
const article = await prisma.article.create({
data
});
return article;
}
次に src/routes/articles/new/+page.server.ts
ファイルにフォームデータを受け取り、createArticle
関数を呼び出して記事を作成する処理を追加します。action は引数として RequestEvent 型のオブジェクトを受け取ります。request.formData()
を呼び出すことで、formData オブジェクトとしてポストされたデータを取得できます。
DB へのデータの保存に成功した場合には redirect()
関数を呼び出して記事の一覧ページへリダイレクトします。
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { createArticle } from '$lib/server/articles';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
// .get() の値は <input> の name 属性の値に対応している
const title = formData.get('title') as string;
const content = formData.get('content') as string;
await createArticle({ title, content });
redirect(303, '/articles');
}
};
これでフォームからデータを送信する処理が実装できました。フォームに適当なタイトルと本文を入力して投稿してみましょう。投稿が成功すると記事一覧ページにリダイレクトされ、投稿した記事が一覧に表示されます。
フォームデータのバリデーション
フォームデータを受け取る際に as string
で型アサーションを行っています。しかし、formData.get()
は null
を返す可能性があるため良い実装ではありません。必須の項目が入力されていなかったり、不正なデータが送信された場合にはエラーメッセージを表示するように修正しましょう。
バリデーションを行うライブラリとして Zod を導入します。Zod は TypeScript で書かれたスキーマ定義を使用して、データのバリデーションを行うライブラリです。
npm install zod
src/lib/server/articles.ts
ファイルに Zod を使用したスキーマを定義しましょう。
import { z } from 'zod';
export const articleSchema = z.object({
title: z.string().min(1).max(20),
content: z.string().min(1).max(1000)
});
このスキーマでは、title
プロパティは 1 文字以上 20 文字以下、content
プロパティは 1 文字以上 1000 文字以下であることを定義しています。
続いて src/routes/articles/new/+page.server.ts
ファイルの action 関数内で articleSchema
を利用してバリデーション処理を行うように修正します。
import { fail, redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
import { articleSchema, createArticle } from '$lib/server/articles';
export const actions: Actions = {
default: async ({ request }) => {
const formData = await request.formData();
const validatedFields = articleSchema.safeParse({
title: formData.get('title'),
content: formData.get('content')
});
// バリデーションに失敗した場合
if (!validatedFields.success) {
return fail(400, {
errors: validatedFields.error.formErrors,
fields: {
title: formData.get('title') as string | undefined,
content: formData.get('content') as string | undefined
}
});
}
await createArticle({
title: validatedFields.data.title,
content: validatedFields.data.content
});
redirect(303, '/articles');
}
};
articleSchema.safeParse()
メソッドを使用してフォームデータがスキーマに適合しているかを検証します。バリデーションに失敗した場合には .safeParse()
の戻り値の success
プロパティが false
となり、error.formErrors
プロパティにエラーメッセージが格納されます。その場合には SvelteKit の fail()
関数を使用して 400 エラーを返します。fail()
関数の第 2 引数にはエラーメッセージと前回のフォームの値を渡します。エラーメッセージはクライアント側で表示するために使用されます。
バリデーションに成功した場合には、validatedFields.data
からデータを取り出して記事を作成します。型アサーションを使用するよりも安全にデータを取り出すことができるようになりました。
続いてバリデーションエラーが発生した場合に、クライアント側でエラーメッセージを表示するように修正します。action から返された値は form
Props としてコンポーネントに渡されます。form
Props は SvelteKit により自動で型が生成されており、fail()
関数に渡したオブジェクトの型に合わせて推論されます。
<script lang="ts">
const { form } = $props();
</script>
form
の fieldErrors
プロパティには各フィールドのエラーメッセージが格納されています。フォームの各フィールドの下にエラーメッセージを表示するようにしましょう。また、前回入力したフォームの値を保持するために、fields
プロパティを使用してフォームの初期値を設定します。
<script lang="ts">
const { form } = $props();
</script>
<form method="POST">
<div class="grid gap-6 mt-6">
<div>
<label for="title" class="block mb-2 text-sm font-medium text-gray-900">タイトル</label>
<input
id="title"
name="title"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
value={form?.fields.title}
aria-invalid={form?.errors.fieldErrors?.title ? 'true' : undefined}
aria-describedby={form?.errors.fieldErrors?.title ? 'title-error' : undefined}
/>
{#if form?.errors.fieldErrors?.title}
<p id="title-error" class="text-red-600 text-sm mt-1">{form.errors.fieldErrors.title}</p>
{/if}
</div>
<div>
<label for="content" class="block mb-2 text-sm font-medium text-gray-900">本文</label>
<textarea
id="content"
name="content"
rows="10"
class="border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
value={form?.fields.content}
aria-invalid={form?.errors.fieldErrors?.content ? 'true' : undefined}
aria-describedby={form?.errors.fieldErrors?.content ? 'content-error' : undefined}
></textarea>
{#if form?.errors.fieldErrors?.content}
<p id="content-error" class="text-red-600 text-sm mt-1">
{form.errors.fieldErrors.content}
</p>
{/if}
</div>
<div>
<button
type="submit"
class="inline-flex justify-center items-center py-3 px-5 text-base font-medium text-center text-white rounded-lg bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-primary-300"
>
投稿する
</button>
</div>
</div>
</form>
これでフォームのバリデーションエラーが発生した場合にエラーメッセージが表示されるようになりました。タイトルに 20 文字以上の文字列を入力して投稿してみましょう。エラーメッセージが表示されることを確認します。
プログレッシブエンハンスメントなフォーム
現在のフォームは HTML の標準機能のみを利用して作成されています。このことは、JavaScript が無効になっている環境でもフォームが正常に動作することを意味し、良い習慣と言えるでしょう。
ただし JavaScript の fetch
API を使用して送信するフォームと比較すると、1 度ポストリクエストを送信してページがフルリロードされる都合上、ユーザーにとっては使い勝手が悪いと感じるかもしれません。SvelteKit はこのような問題を解決するために、JavaScript が有効な環境な場合のみ、よりよいユーザー体験を提供する use:enhance
アクションを提供しています。
使い方は簡単で、<form>
属性に use:enhance
を追加するだけです。
<script lang="ts">
import { enhance } from '$app/forms';
const { form } = $props();
</script>
<form method="POST" use:enhance>
{ /* ... */ }
</form>
enhance
を引数無しで呼び出すと、ブラウザネイティブの動作をページのフルリロードを除きエミュレートします。引数にコールバック関数を受け取り、フォームがサブミットされる直前の動作をカスタマイズすることが可能です。
フォームの文字列をカウントする
ユーザーの利便性を向上させるために、本文の文字数をカウントする機能を追加しましょう。本文のフォームに入力された値を表す content
という状態を新たに定義し、textarea
になにか入力するたびに content
を更新します。
Svelte v5 以降ではリアクティブな状態を定義するために $state()
関数を使うことが推奨されます。$state()
の引数には初期値を渡せるので、content
の初期値は form?.fields.content
としておきます。
<script lang="ts">
import { enhance } from '$app/forms';
const { form } = $props();
let content = $state(form?.fields.content ?? '');
const maxCount = 1000;
さらに、ある状態から派生した状態は $derived()
関数を使って定義できます。$derived()
関数を使って本文のフォームに入力された値から、文字数をカウントする count
という状態を定義します。
<script lang="ts">
let content = $state(form?.fields.content ?? '');
const count = $derived(content.length);
const maxCount = 1000;
</script>
ところで、文字数をカウントするために .length
プロパティを使用することはあまり好ましくないことが知られています。例えば、「🍎」のような絵文字や「𩸽」のようにサロゲートペアで表現される文字は見た目上 1 文字に見えますが、.length
プロパティを使うと期待とは異なる結果が返されます。
"🍎".length; // 2
"𩸽".length; // 2
JavaScript で正しく日本語の文字数をカウントしたい場合には、Intl.Segmenter を使う方法があります。Intl.Segmenter はロケールに応じたテキストのセグメンテーションを行うための API です。Intl.Segmenter がサロゲートペアも考慮されているため、より正確な文字列のカウントが行えます。
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
[...segmenter.segment("🍎")].length; // 1
[...segmenter.segment("𩸽")].length; // 1
$derived()
関数内の実装を Intl.Segmenter を使って書き換えてみたいところですが、$derived()
は単純な式のみを引数に取るため、複数行にまたがるような処理を書くことができません。このような場合には、$derived.by()
関数を使って派生状態を定義できます。
<script lang="ts">
import { enhance } from '$app/forms';
const { form } = $props();
let content = $state(form?.fields.content ?? '');
const count = $derived.by(() => {
const segmenter = new Intl.Segmenter("ja", { granularity: "grapheme" });
return [...segmenter.segment(content)].length;
});
const maxCount = 1000;
</script>
状態の定義が完了したら、textarea
に入力されるたびに content
が更新されるようにしましょう。Svelte では bind:value
ディレクティブを使って、textarea
の値を双方向バインディングできます。
<textarea
id="content"
name="content"
rows="10"
class="border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
bind:value={content}
aria-invalid={form?.errors.fieldErrors?.content ? 'true' : undefined}
aria-describedby={form?.errors.fieldErrors?.content ? 'content-error' : undefined}
></textarea>
最後に、文字数のカウントを表示するようにしましょう。
<div>
<label for="content" class="block mb-2 text-sm font-medium text-gray-900">本文</label>
<textarea
id="content"
name="content"
rows="10"
class="border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5"
required
bind:value={content}
aria-invalid={form?.errors.fieldErrors?.content ? 'true' : undefined}
aria-describedby={form?.errors.fieldErrors?.content ? 'content-error' : undefined}
></textarea>
<p class="text-sm text-gray-500 mt-1" class:text-red-600={count > maxCount}>
{count}/{maxCount}
</p>
{#if form?.errors.fieldErrors?.content}
<p id="content-error" class="text-red-600 text-sm mt-1">
{form.errors.fieldErrors.content}
</p>
{/if}
</div>
class:text-red-600={count > maxCount}
は Svelte で動的にクラスを適用するための構文です。text-red-600
クラスは {count > maxCount}
という条件が true
のときに適用されます。
フォームに文字を入力するたびに、文字数がカウントされることを確認しましょう。
記事の削除
記事の一覧画面で削除ボタンをクリックしたときに、モーダルで確認メッセージを表示し、OK ボタンをクリックすると記事を削除する機能を実装します。まずは src/routes/articles/deleteModal.svelte
ファイルを作成し、モーダルのコンポーネントを作成します。
<script lang="ts">
import { enhance } from '$app/forms';
type Props = {
id: number;
};
const { id }: Props = $props();
// <dialog> 要素の ref を取得する
let modalRef = $state<HTMLDialogElement | null>(null);
const openModal = () => {
modalRef?.showModal();
};
</script>
<button type="button" class="bg-red-500 text-white px-4 py-2 rounded-lg" onclick={openModal}>
削除
</button>
<dialog
bind:this={modalRef}
class="p-4 w-96 backdrop:backdrop-blur-sm backdrop:bg-black/40 bg-white rounded-lg"
aria-labelledby="modal-title"
>
<form use:enhance>
<h2 id="modal-title" class="text-xl font-bold">記事の削除</h2>
<p>本当に削除しますか?</p>
<div class="mt-4 flex justify-end">
<button class="bg-gray-300 text-gray-800 px-4 py-2 rounded-lg" formmethod="dialog">
キャンセル
</button>
<button
class="bg-red-500 text-white px-4 py-2 rounded-lg ml-2"
formmethod="post"
formaction="/articles/{id}/delete"
>
OK
</button>
</div>
</form>
</dialog>
モーダルは HTML 標準の dialog 要素を使用して作成しています。<dialog>
要素は HTMLDialogElement
インターフェイスの showModal()
メソッドを呼び出すことで表示されます。bind:this
で <dialog>
要素の参照をあらかじめ取得しておき、削除ボタンがクリックされたときに showModal()
メソッドを呼び出すようにしています。
CSS の ::backdrop
擬似クラスを使うことでモーダルが表示されたときの背景のスタイルを変更できます。TailwindCSS において擬似クラスを適用する場合には backdrop:bg-black/40
のように backdrop:
をプレフィックスとして指定します。
ダイアログの中身はフォームとなっており、キャンセルボタンの formmethod
には formmethod="dialog"
を指定しています。これにより、キャンセルボタンをクリックするとダイアログが閉じるようになります。OK ボタンをクリックすると、formaction
に指定した URL に POST リクエストが送信されるようになっています。記事の作成画面のように、後ほど +page.server.ts
ファイルでリクエストを処理するようにします。
記事一覧画面に削除ボタンを追加し、モーダルを表示するようにします。カードコンポーネントを修正しましょう。
<script lang="ts">
import DeleteModal from './DeleteModal.svelte';
type Props = {
id: number;
title: string;
createdAt: Date;
};
const { id, title, createdAt }: Props = $props();
</script>
<div
class="bg-white shadow-md rounded-lg p-4 hover:shadow-lg transition duration-300 ease-in-out flex justify-between"
>
<div>
<a href="/articles/{id}" class="hover:underline">
<h2 class="text-lg font-bold">{title}</h2>
</a>
<time class="text-sm text-gray-500" datetime={createdAt.toISOString()}>
{createdAt.toLocaleDateString()}
</time>
</div>
<div>
<DeleteModal {id} />
</div>
</div>
削除ボタンをクリックするとモーダルが表示されることを確認しましょう。
記事の削除処理
サーバーサイドでポストリクエストを処理し、記事を削除する処理を実装しましょう。まずはいつものように lib/server/articles.ts
ファイルに DB から記事を削除する関数を追加します。
export async function deleteArticle(id: number): Promise<void> {
await prisma.article.delete({
where: {
id
}
});
}
リクエストを処理する src/routes/articles/[id]/delete/+page.server.ts
ファイルを作成し、削除処理を実装します。
import { deleteArticle } from '$lib/server/articles';
import { redirect } from '@sveltejs/kit';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ params }) => {
const id = params.id;
await deleteArticle(Number(id));
return redirect(303, '/articles');
}
};
これで削除ボタンをクリックすると、記事が削除されるようになりました。
まとめ
このチュートリアルでは、SvelteKit でブログアプリケーションを作成し、以下の事項について学びました。
- ファイルベースシステムのルーティング
- サーバーサイドでのデータの取得
- フォームの作成と送信処理
$props
によるコンポーネント間のデータの受け渡し$state
による状態の管理
ここでは、SvelteKit の基本的な機能を使ってブログアプリケーションを作成しましたが、SvelteKit には他にも多くの機能が用意されています。例として、SvelteKit の機能を活用して以下のような機能を追加してみると良いでしょう。
- 記事の一覧画面に form action を追加し、検索機能を実装する
- スナップショット により、ユーザーがフォームに入力した内容を保持する
+server.ts
ファイルで API ルート を作成し、RSS フィードを提供する- Vercel Adapter を使って記事の詳細画面に ISR オプションを設定する