RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

【SvelteKit入門】SvelteKit + Prismaによる掲示板アプリ作成

こんにちは!ラクス1年目のkoki_matsuraです。
今回は掲示板アプリ作成を通して、SvelteKitの基礎的な部分をご紹介させていただきます。

目次は下記のようになっています。

はじめに

Svelteとは

WebアプリケーションやUIを構築するためのJavaScriptフレームワークです。有名なものでは「React」や「Vue」が挙げられます。
Svelteにはこれらのフレームワークと比べて下記のような特徴があります。

  1. 仮想DOMを用いない
    こちらが最も大きな特徴となります。
    まず、仮想DOMとはその名前の通りプログラムの中で作られる仮のDOMのことを言います。従来のフレームワークでは変更点があれば毎回DOM全体を構築していましたが、仮想DOMは下図のように変更前と変更後の仮のDOMを比較し、差分を検出することで変更部分のみを構築でき、表示速度も速くなります。
    しかし、Svelteでは仮想DOMを用いていません。従来のように変更のたびに全DOMを再構築しているわけでもありません。 代わりにコンパイル時に特殊なことをします。
    具体的にはビルド時にDOMの構造を解析し、変更の可能性がある部分をVanilla JSのコードとして落とし込む最適化を行います。そして、データに変更があれば該当のJSコードを動かし、実際のDOMを変更します。なので、仮想DOMを必要とせずに差分のみを変更することができ、高速なレンダリングを行えます。 また、JSファイルに全てのロジックが記述されているのでランタイムが不要となり、軽量です。
    これらのことから分かる通り、Svelteはビルド時にJSコードを生成する「コンパイラ」です。

  2. シンプルな構文で記述できる
    Svelteではボイラープレートを記述する必要がなかったり、変数や関数などのアプリケーションの状態を自動的に管理するため状態管理専用ライブラリが入りません。
    よって、記述するコードがほかのフレームワークより少なくて済みます。
    また、テンプレートベースのシンタックスを使用してUIを記述するため、JavaScriptとHTMLのマークアップを分離することができ、かなりコードが見やすくなります。

  3. 真にリアクティブである
    Svelteは、必要なデータを持つ変数を定義し、その変数が変更された時、自動的に画面に反映するといった仕組みを備えています。
    このリアクティブな仕組みにより、変更を手動で書かなくて済み、結果的にコードを書く量が減り、開発効率が向上するといったメリットがあります。

SvelteKitとは

Svelteを使ってWebアプリケーションを開発するためのフレームワークです。ReactにとってのNext、VueにとってのNuxtのようなものです。
開発する上で必要となる環境を自動でセットアップし、アプリケーション開発の作業を簡素化してくれます。具体的にはルーターSSRの基本的なものからビルドの最適化、プリロード、柔軟なレンダリングの設定など高速なページロードをするための機能です。
多くのサポートがあるため、開発者はアプリケーションの開発に集中することができます。

掲示板アプリ作成

ここからは実際にSvelteKitを使って、簡単な掲示板アプリを作成します。
ORMとしてPrismaを使います。

アプリの概要

作成する掲示板アプリで開発する機能は次のようになっています。

  • ユーザー登録
  • ログイン・ログアウト
  • スレッド投稿
  • コメント投稿

必要となるページは次のようになっています。

  • 新規登録画面
  • ログイン画面
  • スレッド一覧画面
  • スレッド詳細画面
  • スレッド投稿画面
  • エラー画面

かなりシンプルな掲示板アプリです。

環境構築

SvelteKit

早速ですが、SvelteKitでプロジェクトを作成しましょう。ターミナルを開き、プロジェクトを作成するディレクトリに移動し、下記のコマンドを打ちます。
プロジェクト名は「board-app」としましたが、お好きな名前で構いません。

npm create svelte@latest [プロジェクト名]

作成時にテンプレートや、JSかTSか、ESLintを使うか、Prettierを使うか、テストにPlaywright、Vitestを使うか聞かれます。今回の場合はテンプレートに「Skeleton project」を選択し、JSかTSかは「TypeScript」を選択します。その他は好きに選んでいただいて構いません。
プロジェクトが作成されれば、次やるべきことがターミナルに表示されます。
僕のターミナルには下記のように表示されました。

Next steps:
  1: cd board-app
  2: npm install (or pnpm install, etc)
  3: git init && git add -A && git commit -m "Initial commit" (optional)
  4: npm run dev -- --open

プロジェクトに移動し、「npm install」をしましょう。Nextを使ってると、そのまま4に行けるのですがSvelteKitの場合は一旦、インストールを挟まないといけないようです。
「npm run dev -- --open」をコマンドで打つと、自動で開きます。下記のような初期画面が開いていれば成功です。
SvelteKitの環境構築は終わりです。

データベース

僕はデータベースにSupabaseを使います。特に理由はありません。なので、他の方法でデータベースを作成していただいても大丈夫です。

もし、Supabaseをこれから使いたいという方は僕が過去に書いた記事を参考にしていただければと思います。
この記事のSupabaseでプロジェクトを作成する部分までで大丈夫です。テーブルの定義はPrismaで行います。
※ Supabaseのプロジェクト作成の際に設定するパスワードを忘れないようにしてください。

tech-blog.rakus.co.jp

Prisma

Prismaをインストールします。下記のコマンドをターミナルに入力します。

npm install prisma --save-dev
npx prisma init

コマンド入力後にプロジェクトにprismaディレクトリが作成され、中には「schema.prisma」が作成されていると思います。
providerは合ったものにしてください。僕はSupabaseを使用しているのでpostgresqlとなります。
urlは「.env」ファイルから「DATABASE_URL」という変数の値を持ってきています。
DATABASE_URLは未設定ですが、テーブル作成の際にSupabaseから取得してきたいと思います。

// schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

一旦、必要なものは揃ったので環境構築は終わりです。

テーブル作成

Svelteを使ってガンガン開発していきたいところですが、まずはテーブルの作成をしていきます。先ほど自動で作成された「schema.prisma」を開きます。

今回の掲示板アプリに必要なのは「User」「Post」「Comment」です。それぞれユーザのテーブル、投稿されたスレッドのテーブル、スレッドに対するコメントのテーブルになっています。
「schema.prisma」に下記のようにテーブル定義します。

// 省略

model User {
  id         String    @id @default(uuid())
  name       String    @unique
  password   String
  authToken  String    @unique
  created_at DateTime  @default(now())
  Comment    Comment[]
  Post       Post[]
}

model Post {
  id         Int       @id @default(autoincrement())
  userId     String
  content    String
  created_at DateTime @default(now())
  Comment    Comment[]
  user       User      @relation(fields: [userId], references: [id])
}

model Comment {
  id         Int      @id @default(autoincrement())
  userId     String
  postId     Int
  content    String
  created_at DateTime @default(now())
  post       Post     @relation(fields: [postId], references: [id])
  user       User     @relation(fields: [userId], references: [id])
}

テーブルが定義されたので、データベースに登録したいところですが、「DATABASE_URL」が未設定なのでその値を取得します。
Supabaseのプロジェクトのサイドバーから「Project Settings」-> 「Database」に移動します。下記のような画面が表示されていれば大丈夫です。 画面の下の方にデータベースのURIがありますのでコピーしましょう。 このURIの[YOUR-PASSWORD]と書かれている部分に自分が設定したパスワードを入力します。
そして、「.env」のDATABASE_URLにペーストします。

// .env
DATABASE_URL= [コピーしたデータベースのURI]

準備が整ったので、ターミナルで下記のコマンドを打ちます

npx prisma migrate dev --name init

成功すれば、prismaディレクトリ下にmigrationsフォルダが作成されます。また、Supabaseの方にもテーブルが作成されていることも確認できます。

データベースを操作する時には「PrismaClient」が必要となるので、src下にlib/prisma.tsを作成して、「prisma.ts」に次のように記述ます。

// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";

export const db = new PrismaClient()

これで、Prismaによるデータベース操作の際はこのファイルをインポートすればすぐに行えます。

ルーティング作成

では、最初にアプリケーションのルーティングを作っていきます。
SvelteKitはsrc/routesがデフォルトのルートです。つまり、routes直下に配置されたページは「/」で表示されます。
src/routes/exampleの直下に配置されたページは「/example」で表示されます。
そして、ページを表すファイルは「+page.svelte」で定義できます。
実際に初期起動時に表示されたページはsrc/routes/+page.svelteの内容となっています。
すごくシンプルなので簡単にルーティングを作成できます。
改めてこの掲示板アプリで必要な画面は下記のようになっています。

  • 新規登録画面
  • ログイン画面
  • スレッド一覧画面
  • スレッド詳細画面
  • スレッド投稿画面

特に複雑な構造になっていないので、下記のようにsrc/routes以下にフォルダを作成していきましょう。

一覧画面はデフォルトのルートに表示するため、フォルダは作成しなくて問題ありません。 詳細画面となるdetail/[postId]の[postId]は動的なルーティングに対応します。
例えば、「/detail/1」や「detail/sample」などを入力してもdetail/[postId]直下にある「+page.svelte」が表示されます。
ルーティングは完成しました。

新規登録画面

最初に新規登録画面を作成いたします。
/register下に「+page.svelte」を作り、下記のような登録フォームを作ります。
デザインは全く取り入れてないので少し見にくいかもです。

// register/+page.svelte
<h1>ユーザー登録</h1>
<form method="post" action="?/register">
    <label>名前:
        <input name="name" type="text" />
    </label>
    <label>パスワード:
        <input name="password" type="password" />
    </label>
    <button>登録する</button>
</form>

当然ながらこれではボタンを押してもリクエスト先がないため400系のエラーしか返ってきません。
サーバサイドのロジックを提供するために必要なのは「+page.server.ts」です。同じディレクトリ内に作るだけで大丈夫です。
余談ですが、SvelteKitのページは「+page.ts」「+page.server.ts」によって支えられています。「+page.ts」はクライアントサイドで実行されます。クライアント側のロジックやユーザインタラクションの処理に用いるのが推奨されます。「+page.server.ts」はサーバー側で実行されます。SSRのロジックや、データベースからデータを取得する処理などを記述することが推奨されます。

「+page.server.ts」に次のように記述します。

// register/+page.server.ts
import { fail, redirect } from "@sveltejs/kit";
import type { Actions } from "./$types";
import bcrypt from 'bcrypt'
import { db } from "$lib/prisma";

export const actions: Actions = {
    register : async ({request}) => {
        const data = await request.formData();
        const name = data.get("name");
        const password = data.get("password");

        if (typeof name !== "string" ||typeof password !== "string" || !name || !password) {
            return fail(400, { message: "名前・パスワードは必須です。" })
        }

        const user = await db.user.findUnique({
            where: {name}
        })

        if (user) {
            return fail(400, { message: "既に存在するユーザーです。" })
        }
        
        await db.user.create({
            data: {
                name,
                password : await bcrypt.hash(password, 10),
                authToken: crypto.randomUUID(),
            },
        })

        throw redirect(303, '/login')
    }
}

actionsには様々なサーバー側の処理を記述します。型はActionsです。Actionsのインポート先である./$typesはSvelteKitのコンポーネントや関数の型情報を提供しています。そして、Actionsはイベントや条件に対応して実行される関数です。
上記のコードでは「register」が定義されています。これは「+page.svelte」のformタグのactionの値に対応します。
引数にはrequestを入れていますが、他にはcookieやsetHeaderなどサーバーサイドでの処理に必要な情報が提供されます。

具体的なコードの説明としては、下記のようになります。
1. request.FormData()でformで入力した名前とパスワードのデータを取得します。
2. data.get([inputのname値])で各々のデータを取得します。
3. 型のチェックと入力のチェックをします。もし、未入力の場合はfail関数を返します。第一引数にステータスコードを第二引数に任意のデータをオブジェクトで入れます。
4. チェックが問題なければ、Prismaを使って、既に存在する名前かデータベースに検索をかけます。存在しているならfail関数を返します。
5. 存在していないユーザーならCreateで名前とハッシュ化したパスワード、トークンを登録します。
6. 全て完了すれば、redirect関数でログイン画面にリダイレクトさせます。

SvelteKitはデータベースへの接続を自動で切ってくれるのでdisconnectの処理を入れなくても問題ありません。
「+page.server.ts」での処理も書き終わりました。
登録ができて、ログイン画面のURLに飛ばされれば大丈夫です。僕は名前「user」パスワード「password」で登録すると、Supabaseの方にも反映されていました。

正常処理は実装できましたが、存在するユーザー名を入力した時に返されてくるfail関数の処理を実装できていません。
「+page.svelte」に下記のコードを追加するだけで簡単に登録バリデーションが実装できます。

// register/+page.svelte
<script lang="ts">
    import type { ActionData } from "./$types"
    export let form: ActionData
</script>
<h1>ユーザー登録</h1>
{#if form?.message}
  <p class="error">{form?.message}</p>
{/if}

/* HTML省略 */

<style>
    .error {
        color: red;
    }
</style>

actionはリクエスト処理後、次の更新まで対応のページのformプロパティにデータを返します。つまり、fail関数の第二引数のデータはformに返されます。なので、form.messageでバリデーションメッセージを取り出せます。
実際に存在する名前を入力すると、次のように表示されると思われます。
これで新規登録画面が作成できました。

ログイン画面

ログイン画面を作ります。
まずは/login下に「+page.svelte」を作成し、ログインフォームを作ります。
登録フォームとほとんど一緒です。今回は「+page.server.ts」からくるバリデーションメッセージを想定して、formを用意しておきます。

// login/+page.svelte
<script lang="ts">
    import type {ActionData} from './$types';
    export let form: ActionData
</script>

<h1>ログイン画面</h1>
{#if form?.message}
    <p class="error">{form.message}</p>
{/if}
<form method="POST" action="?/login">
    <label>名前:
        <input name="name" type="text" />
    </label>
    <label>
        パスワード:
        <input name="password" type="password" />
    </label>
    <button>ログイン</button>
</form>
<style>
    .error {
        color: red;
    }
</style>

このログイン機能ではセッションを使うので、「+page.server.ts」では、引数にrequestだけでなくcookiesを取り入れます。

// login/+page.server.ts

import { db } from "$lib/prisma";
import { type Actions, fail, redirect} from "@sveltejs/kit";
import bcrypt from "bcrypt";

export const actions: Actions = {
    login: async ({request, cookies}) => {
        const data = await request.formData();
        const name = data.get("name");
        const password = data.get("password");
    
        if (typeof name !== "string" || typeof password !== "string" || !name || !password) {
            return fail(400, { message: "名前とパスワードを入力してください" })
        }
        const user = await db.user.findUnique({
           where: {name}
        })

        if (!user) {
            return fail(400, { message: "名前またはパスワードを間違えています" });
        }

        const correctPassword = await bcrypt.compare(password, user.password)

        if (!correctPassword) {
            return fail(400, { message: "名前またはパスワードを間違えています"})
        }

        const authenticatedUser = await db.user.update({
            where: { name },
            data: {
                authToken: crypto.randomUUID()
            }
        })

        cookies.set('session', authenticatedUser.authToken, {
            path: '/',
            maxAge: 60*60*24*30,
        })

        throw redirect(303, '/')
    }
}

前半部分は入力に対してのバリデーション、そして次に名前でUserテーブルに検索をかけ、存在すれば、パスワードを一致するか比較する。
パスワードも一致していれば、該当のユーザーのauthTokenを更新する。
そして、最後にcookiesにauthTokenをセットします。setの第一引数にはcookieの名前、第二引数には値を、そして第三引数にはオプションをセットします。オプションにはいろいろありますが、今回はアプリ全体で使用したいのでpathを「/」にし、有効期限は1ヶ月にしました。

ログイン画面で名前に先ほど登録した名前、パスワードでログインしましょう。
成功すれば初期画面にリダイレクトされ、cookieが登録されるはずです。

ログイン機能ができましたが、今のままではログインを通らずに一覧を見れてしまうため、cookieの意味がありません。
なので、ログインと新規登録画面以外にはcookieが登録されていない場合には遷移できないようにします。
「ログイン・新規登録画面以外にcookie登録されていない場合」のような処理を書くのはあまりにも非効率です。一度に全ての画面に処理を反映したいですよね。
そのような時はルートディレクトsrc/routesに「+layout.server.ts」を作成します。そして下記のようなコードを記述してください。

// +layout.server.ts

import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";

export const load: LayoutServerLoad = async ({ url, cookies  }) => {
    const session = await cookies.get('session')
    if(!session && (url.pathname !== '/login' && url.pathname!== '/register')) {
        throw redirect(303, '/login')
    }
}

「+layout.server.ts」は作成されたディレクトリ以下の全てに処理を入れることができるファイルです。なので、遷移するたびに挟みたい処理などはlayoutファイルを使うことが推奨されます。
後にHeader作成時にも使いますが「+layout.svelte」を使うと同様にそのディレクトリ以下、全てにファイルの情報が共有されます。共通部分を作るときに推奨されます。

load関数はページが読み込まれる前に行う処理を記述できます。 cookiesにsessionが登録されていないときにログイン画面と登録画面以外に遷移すると強制的にログイン画面にリダイレクトされるようにしました。
これでログインせずに一覧へ遷移することはできなくなりました。

スレッド投稿画面

スレッドの投稿画面を実装します。
順序的には一覧画面から実装するべきかもしれませんが、一覧は投稿がなければ表示するものがないので、先に投稿できるようにします。
post/下に「+page.svelte」「+page.server.ts」を作成します。
「+page.svelte」は次のようなコードにします。コードは登録画面やログイン画面とあまり変わりません。

// post/+page.svelte

<script lang="ts">
    import type { ActionData } from "./$types"
    export let form : ActionData
</script>
<h1>スレッド投稿</h1>
{#if form?.message}
{form.message}
{/if}
<form method="POST" action="?/post">
    <label>内容:
        <input name="content" type="text" />
    </label>
    <button>投稿する</button>
</form>

「+page.server.ts」は少し考えなければなりません。
スレッドを投稿するためには、postテーブルにデータを渡すのですが、登録にはuserIdが必要となります。もちろん、userテーブルからauthTokenで検索をすれば解決する話です。しかし、コメント投稿の際にも同じようにuserIdが必要になります。このような部分は共通化しましょう。
src下に「hooks.server.ts」を作成します。「hooks.server.ts」はサーバーがリクエストを受けるたびに実行されます。このファイル内でユーザー情報を検索し、アプリケーション内で共有できるようにすれば、何度もuserを検索するコードを書く手間が省けます。
「hooks.server.ts」は次のように記述してください。

// src/hooks.server.ts

import { db } from "$lib/prisma";
import type { Handle } from "@sveltejs/kit";

export const handle: Handle = async ({ event, resolve }) => {
    const session = event.cookies.get('session');
    
    if (!session) {
        return await  resolve(event)
    }

    const user = await db.user.findUnique({
        where: { authToken: session},
        select: {id: true, name: true}
    })

    if (user) {
        event.locals.user = {
            id: user.id,
            name: user.name
        }
    }
    return await resolve(event)
}

userテーブルから検索をするためにsessionの値が必要となるのでcookiesを取得します。また、検索をするときはユーザー情報を全て取り出すのではなく、IDと名前だけ取り出します。
そして、event.localsにuser情報を格納します。localsに格納した情報はアプリ内で使うことが可能になります。
TypeScriptで書いている方はuserを型定義しないといけません。「app.d.ts」を次のように書き換えてください。

// src/app.d.ts

declare global {
    namespace App {
        // interface Error {}
        interface Locals {
            user: {
                id: string,
                name: string
            }
        }
        // interface PageData {}
        // interface Platform {}
    }
}

export {};

これにより、「+page.server.ts」は次のように書くだけで投稿することができるようになります。

// post/+page.server.ts
import { db } from "$lib/prisma";
import { fail, redirect, type Actions } from "@sveltejs/kit";

export const actions: Actions = {
    post : async ({request, locals}) => {
        const data = await request.formData()
        const content = data.get("content");

        if (typeof content !== "string" || !content) {
            return fail(400, { message: "タイトルと内容は必須入力です。"})
        }

        if (!locals.user) return fail(400, {message: "登録されていないユーザーです。"})

        await db.post.create({
            data: {
                userId: locals.user.id,
                content
            }
        })

        throw redirect(303, '/')
    }
}

sessionを取得せずに、localsからuserIDを取り出して、postテーブルのuserIdに割り当てています。
投稿が成功すれば、一覧へリダイレクトします。
これで投稿画面が完成しました。

スレッド一覧画面

スレッドを一覧できる画面を実装します。
一覧では、それぞれのスレッドをコンポーネントを使って表示してみたいと思います。 src/libに「Thread.svelte」を作成してください。コードは次のようにしてください。

// lib/Thread.svelte

<script lang="ts">
    export let id : number;
    export let content: string;
</script>

<div class="thread">
    <div class="thread-content">
      <h2>{content}</h2>
      <a href="detail/{id}">スレッドの詳細を見る</a>
    </div>
</div>

<style> 
.thread {
  display: flex;
  flex-direction: row;
  width: 100%;
  margin: 10px auto;
  box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23);
}

.thread-content {
  width: 100%;
  margin-left: 10px;
}
.thread-content a {
  display: inline-block;
  font-size: 14px;
  color: blue;
  text-decoration: none;
}
</style>

少しだけデザインをしてみました。目的はコンポーネントを作ることなので正直デザインはどちらでも大丈夫です。
大事なポイントはIDとContentを変数で宣言し、それをHTML内で使用していることです。ReactのPropsと同じです。

では一覧を作成していきます。 src/routes下にデフォルトで作成されていた「+page.svelte」のコードを下記のように書き換えてください。

// +page.svelte

<script lang="ts">
    import {goto} from "$app/navigation";
    import type { PageData } from "./$types";
    import Thread from "$lib/Thread.svelte";
    export let data: PageData
</script>
<main>
   <h1>スレッド一覧</h1>
    <button type="button"  on:click={() => goto("/post")}>投稿する</button>
    {#each data.threads as thread }
       <Thread id={thread.id} content={thread.content} />
    {/each}
</main>

Threadコンポーネントは他と同じようにインポートするだけで使えるようになります。
PageData型はサーバーサイドで生成されたデータを、クライアント側で使用するためのものです。なので、dataにはサーバー側からスレッド一覧のデータが送られてくることを想定しています。
HTMLには「投稿する」ボタンとeach文でスレッドコンポーネントを並べていきます。Propsと同じでidにthread.idを、contentにthread.contentを渡しています。
「投稿する」ボタンをクリック時には、goto関数で投稿画面へナビゲーションをしています。
次に、「+page.server.ts」を作成して次のようなコードにしましょう。

// +page.server.ts

import { db } from "$lib/prisma"
import type { PageServerLoad } from "./$types";

export const load: PageServerLoad = async () => {
    const threads = await db.post.findMany({
        orderBy:{ id: 'desc' },
    });
    return {threads}
}

データを取得したいタイミングは一覧表示前なので、PageServerLoad型を使います。
そして、postテーブルからID降順で全件取得するだけです。
うまくいけば、次のように投稿した内容がThreadコンポーネントを介して表示されるはずです。
また、「スレッドの詳細を見る」を押すと、「/detail/[id]」に飛ぶことも確認できます。もちろん、詳細画面は未作成なので404エラーが表示されます。

スレッド詳細画面

詳細画面では、コメントを投稿することができるようにします。と言っても、特に新しいことをするわけではありません。
いつも通り、「+page.svelte」「+page.server.ts」を作成します。
これからやるべき事は

  • load関数で詳細情報(タイトル・作成者・作成日時)とコメント(内容・作成者・作成日時)の取得
  • 詳細画面にスレッドの情報と投稿されたコメントを表示
  • コメントフォームの作成
  • コメントを投稿するアクションの作成

今までやってきたことの振り返りみたいな感じですね。
「load関数で詳細情報とコメントの取得」からやっていきます。
postテーブルから詳細情報を、commentテーブルからpostIdに該当するコメントを取得してくるのがシンプルでいいかもしれませんが、postテーブルが定義されている「schema.prisma」を見返してみましょう。

model Post {
  id         Int       @id @default(autoincrement())
  userId     String
  content    String
  created_at DateTime  @default(now())
  Comment    Comment[]
  user       User      @relation(fields: [userId], references: [id])
}

PostテーブルはCommentの情報を持っています。また、Userの情報も取り出せそうです。
なので、次のように書くことで複雑ではありますが一回の操作で欲しい情報を取得することが可能です。

// detail/[postId]/+page.server.ts

import { db } from "$lib/prisma"
import { fail } from "@sveltejs/kit";
import type { PageServerLoad } from "./$types"

export const load: PageServerLoad = async ({ params} ) => {
    const threadDetail = await db.post.findUnique({
        where: {
            id : Number(params.postId) 
        },
        include: {
            Comment : {
                orderBy: { id : 'desc'},
                select: {
                    content: true,
                    created_at: true,
                    user: {
                        select:{
                            name: true
                        }
                    }
                }
            },
            user: {
                select: {
                    name: true
                }
            }
        },
    });
    
    if(!threadDetail) throw error(404, {message: "存在しないスレッドです。"})

    return { threadDetail }
}

paramsには{ postId : {id} }が入っているため、params.postIdで必要なIDを取得できます。
次にincludeを使って、Commentテーブルからはコメントと作成日時、作成者を取り出していますUserテーブルからはこのスレッドの作成者を取り出しています。
includeとは、関連するデータを一緒に取得するために用いられます。これによりクエリ数が減少し、パフォーマンスが上がるというメリットがあります。
スレッドの詳細情報とそれに付随するコメントをまとめたthreadDetailをクライアント側に返します。もし、存在しないスレッドの場合はエラーを投げます。エラーページについては後で書きます。

「 詳細画面にスレッドの情報と投稿されたコメントを表示」 をします。「+page.svelte」に次のコードを書いてください。

// detail/[postId]/+page.svelte

<script lang="ts">
    import type { ActionData, PageData } from "./$types";
    type comment = {
        user: {
            name: string
        }
        content: string
        created_at: Date
    }

    export let data: PageData;

    const threadAuthor : string = data.user.name
    const threadContent : string = data.threadDetail.content
    const threadCreatedAt: Date = data.threadDetail.created_at
    const comments : comment[] = data.threadDetail.Comment
</script>
<div>
    <a href="/"> < 一覧に戻る</a>
</div>

<h1>スレッド詳細</h1>
<h2>{threadContent}</h2>
<p>作成者:{threadAuthor} 作成日時:{threadCreatedAt}</p>
<h2>コメント</h2>
{#if comments.length}
    {#each comments as comment}
        <p>名前:{comment.user.name}</p>
        <p>日時:{comment.created_at}</p>
        <p>コメント:{comment.content}</p>
        <br />
    {/each}
{:else}
    <p>コメントはありません</p>
{/if}

サーバーから取得してきたデータは少々複雑になっているので、スレッド作成者、内容、作成日時、コメントの4つに分けます。
また、コメントの中にも名前、作成日時、内容があるのでcomment型を定義して、commentsに格納します。今回の場合、カスタムな型定義がこれだけなので同じファイルに書きましたが、複数ある場合はsrc/lib下などに型定義用のファイルなどを作るようにしましょう。
スレッドの情報が取得できていれば、下記のように画面に表示されると思います。

続いて、「コメントフォームの作成 」をします。全然難しくありません。「+page.svelte」に次のコードを加えるだけです。

// detail/[postId]/+page.svelte

<script lang="ts">
   // 省略
   /* 追加 => */ export let form: ActionData;
</script>

/* HTML省略*/

<div>
{#if form?.message}
    <p class="error">{form.message}</p>
{/if}
</div>
<form method="POST" action="?/comment">
    <input name="comment" type="text" />
    <button>コメントする</button>
</form>

<style>
    .error {
        color: red;
    }
</style>

"コメントがありません"の下にコメント用のフォームが作成されたはずです。
あとは「コメントを投稿するアクションの作成」をすれば終わりです。「+page.server.ts」のload関数の下に次のコードを加えてください。

//  detail/[postId]/+page.server.ts
export const actions: Actions = {
    comment : async ({request,locals, params}) => {
        const data = await request.formData();
        const comment = data.get("comment");
        if (typeof comment != "string" || !comment) {
            return fail(400, { message: "コメントは必須入力です。" })
        }
        
        await db.comment.create({
            data: {
                userId: locals.user.id,
                postId: Number(params.postId),
                content: comment
            }
        })
    }
}

詳細画面で適当なコメントをしてみましょう。 ID降順なので、最新のものが上に来るようになっていれば問題ありません。
また、他のスレッド詳細を見てもコメントがないことを確認しておきましょう。

詳細画面も完成しました。
残すはログアウト機能とエラーページ作成です。しかし、やる事はそれほど難しくありません。おまけみたいな感じで聞いてもらえるとありがたいです。

ログアウト機能

ログアウトするというのはcookie情報を消すということです。それだけです。
src/routes下にlogoutフォルダとlogoutフォルダ下に「+page.server.ts」を作成してください。
「+page.server.ts」内で行うことはcookieを消して、ログイン画面にリダイレクトさせるだけです。念の為にもし、「/logout」に遷移した場合でもload関数で一覧へ戻るようにしておきます。

// logout/+page.server.ts

import { redirect } from "@sveltejs/kit"
import type { Actions } from "./$types"

export const load: PageServerLoad = async () => {
    throw redirect(303, '/');
}

export const actions: Actions = {
    logout: async ({ cookies }) => {
        cookies.delete('session');
        
        throw redirect(303, '/login')
    }
}

ログアウトの処理はできたのでログアウトボタンをヘッダーに配置したいと思います。ヘッダーは全ての画面で共有したいので、src/routes下に「+layout.svelte」を作成します。
「+layout.svelte」は次のようなコードにします。

// +layout.svelte
<script lang="ts">
    import { page } from '$app/stores'
</script>
{#if !$page.data.user}
    <a href="/login">ログイン</a>
    <a href="/register">新規登録</a>
{:else}
    <form action="/logout?/logout" method="POST">
        <button>ログアウト</button>
    </form>
{/if}

<slot />

$app/storesは「+layout.server.ts」から返されるlocals.userの情報を含んでおり、ログインしていれば「$page.data.user」には値が入っているのでログアウトを表示し、値がない時には新規登録とログインへのリンクを表示します。
ログアウトボタンを押せば、logout/+page.server.tsのlogoutが発火します。 これでログアウト機能ができました。

エラー画面

最後にエラー画面を作っていきます。SvelteKitでは簡単にエラー時に特定のエラー画面に飛ばすことができます。
先ほど、詳細画面を作成しているとき、存在しないIDを取得しようとすると「+page.server.ts」ではerrorを投げていました。

// detail/[postId]/+page.server.ts

 if(!threadDetail) throw error(404, {message: "存在しないスレッドです。"})

SvelteKitでは、loadの中でerrorが発生した場合、最も近くにある「+error.svelte」が呼び出されます。なので、detail/[postId]に「+error.svelte」を作成することでカスタムなエラー画面を表示させることができます。

// detail/[postId]/+error.svelte

<script lang="ts">
    import { page } from '$app/stores';
</script>

<h1>エラーページ</h1>
<h3>{$page.error?.message}</h3>

pageからerrorに格納したmessageを取り出すことができます。とても簡単に実装できました。
実際に「detail/[存在しない投稿のID]」に訪れると、エラーページが表示されました。

ちなみに、エラーが発生した場所から最も近い「+error.svelte」が呼び出されるという特徴から、src/routes/下に「+error.svelte」を作るとログイン画面や登録画面でerrorが投げられてもsrc/routes/+error.svelteが表示されるようになります。
404エラー画面が完成しました。
しかし、500エラーには対応できていません。例えば、「/detail/sample」に訪れてください。postIdは数値を想定しているので文字列を入れられると500エラーが発生します。
「+error.svelte」が表示されると思います。

特に問題はないのですがデフォルトの文字列が表示されています。この文字列を変えたい場合には「hooks.server.ts」に次のコードを加えるだけです。

// src/hooks.server.ts

export const handle: Handle = async ({ event, resolve }) => {
    // 省略
}

export const handleError: HandleServerError = ({event}) => {
    return {
      message: event.url.pathname + "で500エラーが発生!",
    };
}

HandleServerErrorは予期せぬエラー発生時に実行されます。もう一度、「/detail/sample」に訪れると表示が変わると思います。

今回はパスを表示していますが、他にも色々な情報を表示できるのでお好きなように設定してください。
500エラーにも対応したエラー画面が完成しました。
これにて、シンプルすぎる掲示板アプリが完成しました!お疲れ様でした!!
本来であれば、自身の投稿やコメントを編集・削除できるべきなのですが、記事がさらに長くなってしまうことと、投稿を実装できていれば編集・削除はそれほど難しくないため省かせていただきました。ぜひ、独力で実装してみてください!

終わりに

今回は掲示板アプリ作成を通してSvelteKitの基礎をご紹介させていただきました。
と言っても、まだまだSvelte・SvelteKitには様々な機能がありますので、これを機にもっと勉強していただけると嬉しいです。
僕自身も最近になって勉強し始めたばかりなので、もっとSvelte・SvelteKitの知識を深めていくつもりです。
本当に長い記事になってしまいましたが、ここまで読んでいただいた方々、少しでも覗いていただけた方々、ありがとうございました!


エンジニア中途採用サイト
ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
ご興味ありましたら是非ご確認をお願いします。
20210916153018
https://career-recruit.rakus.co.jp/career_engineer/

カジュアル面談お申込みフォーム
どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
以下フォームよりお申込みください。
rakus.hubspotpagebuilder.com

ラクスDevelopers登録フォーム
20220701175429
https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/

イベント情報
会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください!

◆TECH PLAY
techplay.jp

◆connpass
rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.