🌦️

TanStack Start と Drizzle ORM で構築する Full-Stack TypeScript

2025/02/09に公開

概要

Web アプリにおいて、フロントエンドとバックエンドを TypeScript で統一することができる技術スタックは、一般的に Full-Stack TypeScript と呼ばれています。

https://zenn.dev/ascend/articles/full-stack-typescript

直近でも、T3 Turbo や Hono の記事が注目を集めていました。


最近 β 版になったばかりの TanStack Start と 、TypeScript で型安全にデータベースを操作できる Drizzle ORM を組み合わせると、Full-Stack TypeScript アプリケーションを簡単に実現することができます。

自分はこの技術スタックを TanStack Drizzle と呼んでいます!

誰でも気軽に使えるように、プロジェクトの雛形に使えるリポジトリを作成しました。

https://github.com/lef237/tanstack-drizzle

今回の記事では、このリポジトリを元に解説していきます。

セットアップ手順

先ほど紹介したリポジトリ、lef237/tanstack-drizzleを clone します。

gh repo clone lef237/tanstack-drizzle

あとは移動して npm install をしたら下準備は完了です。

cd tanstack-drizzle
npm install

今回は PostgreSQL を選択します。

事前にデータベースを用意する必要があります。こちらのドキュメント通りに進めれば、Docker で簡単に用意することができます(Docker Desktop が必要です)。

https://orm.drizzle.team/docs/guides/postgresql-local-setup

データベースを作成したら、次はプロジェクトのルートに.envファイルを置きます。

DATABASE_URL=postgres://postgres:mypassword@localhost:5432/postgres

こんな感じで設定できていれば OK!

次に、Database がきちんと動くかどうかをテストします。

npx drizzle-kit push

問題なく DB と接続できれば、[✓] Changes appliedという表示が出るはずです。

Drizzle 経由で DB を操作するテストコードも動かしてみましょう。

npx tsx app/utils/test.ts

次の結果が console.log として出力されます。

inserted [ { id: 1, title: 'First Todo', completed: false } ]
results [ { id: 1, title: 'First Todo', completed: false } ]
updated [ { id: 1, title: 'First Todo', completed: true } ]
deleted [ { id: 1, title: 'First Todo', completed: true } ]

ここまで来れば、準備は完了です。🤩

次のコードを実行して、TanStack Start を起動しましょう!

npm run dev

http://localhost:3000/を開くと、次の画面が表示されると思います。

上のヘッダーにある「Todos」をクリックしてみてください。

ここで、TanStack Start の Server Functions, Drizzle ORM による DB 操作を使った CRUD 操作を試すことができます。

一覧・追加画面

編集・削除画面

ここからは実際のコードを説明していきます。

Drizzle ORM

https://orm.drizzle.team/

Drizzle ORM の魅力は色々あります。型安全性はもちろん、Edge 環境でも動くことや、DSL を必要としないこと(TypeScript で書けること)。個人的に TS の中ではイチオシの ORM です。

https://youtu.be/i_mAHOhpBSA?si=Vzr4h4sNM7AUPTE8

Drizzle の魅力については上記の Fireship の動画で解説できたものとして、実際にコードを解説します。

db/schema.ts

以下のコードでスキーマを定義しています。

import { pgTable, serial, text, boolean } from "drizzle-orm/pg-core";

export const todosTable = pgTable("todos", {
  id: serial("id").primaryKey(),
  title: text("title").notNull(),
  completed: boolean("completed").notNull().default(false),
});

今回は id を SERIAL 型の主キーに設定し、Todoの title には NOT NULL制約をつけています。completedは完了・未完了のために定義しています。export することで、型を共有しています。

TypeScript で書くことができ、コード補完も効きやすいです。

db/drizzleConnect.ts

DB を Drizzle に繋げているコードです。

import { drizzle } from "drizzle-orm/node-postgres";

export const db = drizzle(process.env.DATABASE_URL!);

今回は PostgreSQL を選択したため、node-postgresになっています。

もし、MySQL や SQLite など、他の DB を使う場合は、公式のドキュメントを参考に適宜書き換えてください。

drizzle.config.ts

設定ファイルも TypeScript で書きます。

import "dotenv/config";
import { defineConfig } from "drizzle-kit";

export default defineConfig({
  out: "./drizzle",
  schema: "./app/db/schema.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

先ほどnpx drizzle-kit pushというコマンドでマイグレーションをおこないました。この drizzle-kit を使うために必要なファイルとなります。

dialectには現在postgresqlが指定されています。もし MySQL を使う場合はmysql, SQLite の場合はsqliteになります。

app/utils/test.ts

先ほど動作確認のために、このコマンド(npx tsx app/utils/test.ts)を実行しました。

テストコードの中身を簡単に紹介して、Drizzle の動きを説明します。

以下ではINSERT, SELECT, UPDATE, DELETEの一連の処理をおこなっています。

import "dotenv/config";
import { eq } from "drizzle-orm";
import { todosTable } from "~/db/schema";
import { db } from "~/db/drizzleConnect";

async function main() {
  // INSERT
  const inserted = await db
    .insert(todosTable)
    .values({ title: "First Todo" })
    .returning();
  console.log("inserted", inserted);

  // SELECT
  const results = await db.select().from(todosTable);
  console.log("results", results);

  // UPDATE
  const updated = await db
    .update(todosTable)
    .set({ completed: true })
    .where(eq(todosTable.id, inserted[0].id))
    .returning();
  console.log("updated", updated);

  // DELETE
  const deleted = await db
    .delete(todosTable)
    .where(eq(todosTable.id, inserted[0].id))
    .returning();
  console.log("deleted", deleted);
}

main();

SQL が得意な方ならスラスラと読める作りになっています。

PostgreSQL のRETURNING句に対応する.returning()を使うこともできます。

eq()WHERE句で等価比較=を行う関数です。もちろん、<>EXISTS, BETWEEN, LIKEなども関数として定義されています。

公式ドキュメントの以下のページに詳しく書かれています。

https://orm.drizzle.team/docs/operators#eq

IDE でこのファイルを開き、変数に対してカーソルを当てると、型が表示されるのを確認できます。

次に、Server Functions を使って、これらの型情報をバックエンドとフロントエンドで共有できるようにしていきましょう。

TanStack Start とは

Server Functions の話をする前に、まずは TanStack Start について簡単に説明します。

https://tanstack.com/start/latest

上記のページのキャッチコピーにはこのように書かれています。

Full-stack React framework powered by TanStack Router

「TanStack Router の機能に、SSR や API Routes などサーバーの機能を追加したもの!」と捉えると分かりやすいです。

そして、この TanStack Start の一番の目玉が Server Functions (RPCs) となっています。

Server Functions の何が嬉しいか?

Server Functions を利用すると、クライアント側からでもサーバー側からでも型安全に関数を呼び出すことができます。

この関数はサーバー側で実行されます。つまり、これらの関数で DB 操作をすれば、API Routes を利用せずにフロントエンドとバックエンドを疎通できます。

ここまでの説明だと React Server Functions のことだと思いそうですが、公式ドキュメントをよく読むと違いました。TanStack Start の Server Functions は特定のフレームワークに縛られないつくりになっているそうです。つまり、(将来的には)React 以外のフレームワークにも適用できるはずです。

更に、標準的な HTTP リクエストをベースに実装しているため、"serial-execution bottlenecks"(シリアル実行のボトルネック)に悩まされることなく、好きなだけ呼び出せるそうです。

サーバー側のコード

まずは、Server Functions を定義する、サーバー側のコードを見てみましょう。

このコードは基本的にどこに置いてもよく、公式のサンプルコードではutils/に置かれています。

Server Functions のみを定義したファイルを置く場所を作るため、functions/ディレクトリを作成します。

app/functions/todoServer.ts

Server Functions が置かれている核となるファイルです。

テーブル名に対応して、todoServer.tsと名付けています。

実際にコードを見てみましょう。

import { createServerFn } from "@tanstack/start";
import { todosTable } from "~/db/schema";
import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "~/db/drizzleConnect";

export const getTodos = createServerFn({ method: "GET" }).handler(async () => {
  return db.select().from(todosTable);
});

export const createTodo = createServerFn({ method: "POST" })
  .validator(z.object({ title: z.string().min(1) }))
  .handler(async ({ data }) => {
    const [inserted] = await db
      .insert(todosTable)
      .values({ title: data.title })
      .returning();
    return inserted;
  });

export const getTodoById = createServerFn({ method: "GET" })
  .validator(z.object({ id: z.number() }))
  .handler(async ({ data }) => {
    const [todo] = await db
      .select()
      .from(todosTable)
      .where(eq(todosTable.id, data.id));
    if (!todo) {
      throw new Error(`Todo not found: id=${data.id}`);
    }
    return todo;
  });

export const updateTodo = createServerFn({ method: "POST" })
  .validator(
    z.object({
      id: z.number(),
      title: z.string().optional(),
      completed: z.boolean().optional(),
    })
  )
  .handler(async ({ data }) => {
    const [updated] = await db
      .update(todosTable)
      .set({
        title: data.title,
        completed: data.completed,
      })
      .where(eq(todosTable.id, data.id))
      .returning();
    if (!updated) {
      throw new Error(`Todo not found: id=${data.id}`);
    }
    return updated;
  });

export const deleteTodo = createServerFn({ method: "POST" })
  .validator(z.object({ id: z.number() }))
  .handler(async ({ data }) => {
    const [deleted] = await db
      .delete(todosTable)
      .where(eq(todosTable.id, data.id))
      .returning();
    if (!deleted) {
      throw new Error(`Todo not found: id=${data.id}`);
    }
    return { success: true };
  });

5つの関数が定義されていることが分かると思います。それぞれの対応関係は次のようになっています。

  • getTodos
    • Todo 一覧を取得する
  • createTodo
    • 新しい Todo を追加する
  • getTodoById
    • Id を元に対象の Todo を取得する
  • updateTodo
    • Todo を更新する
  • deleteTodo
    • Todo を削除する

すべてを紹介すると大変なので、getTodoscreateTodoに絞って解説します。

getTodos

export const getTodos = createServerFn({ method: "GET" }).handler(async () => {
  return db.select().from(todosTable);
});

まずはcreateServerFnです。ここに HTTP リクエストメソッドを書きます。今回は Todo を取得するので GET を指定します。

そして、次に.handlerがあります。ここで、非同期関数として実際に処理(ハンドル)するコードを書きます。

今回は Drizzle を使って、DB からtodosTableの全データを取得しています。

おまけ:この時点での getTodos の型

getTodos の型はこのようになっています。関数の戻り値の型が埋め込まれているのが分かります。

const getTodos: OptionalFetcher<
  undefined,
  undefined,
  {
    id: number;
    title: string;
    completed: boolean;
  }[]
>;

createTodo

次に、新しい Todo を作成する関数です。

export const createTodo = createServerFn({ method: "POST" })
  .validator(z.object({ title: z.string().min(1) }))
  .handler(async ({ data }) => {
    const [inserted] = await db
      .insert(todosTable)
      .values({ title: data.title })
      .returning();
    return inserted;
  });

先程のgetTodosと異なり、POST を使っています。

補足:Method の型

※現在は GET と POST のみに対応しているようです。

createServerFn.d.ts
export type Method = 'GET' | 'POST';

次に登場するのが.validatorです。ここで受け取ったデータのバリデーションをすることができます。

The return type of the validator function will be the input to the server function's handler.

上記のコードでは Zod を使ってバリデーションしています。

z.object({ title: z.string().min(1) });

このコードの場合、データには「1 文字以上の文字列」が必要となります。

(Zod 以外のバリデーターを使うことも可能です!)

そして、バリデーションしたコードを使って、DB へと処理をおこないます。

  .handler(async ({ data }) => {
    const [inserted] = await db
      .insert(todosTable)
      .values({ title: data.title })
      .returning();
    return inserted;
  });

datatitle を DB へと INSERT しています。

また、Server Functions は関数なので、値を返すことができます。この関数ではinsertedを呼び出し側に返しています。

もし、結果を返さなくて良いときは、もっとシンプルに次のように書けます!

  .handler(async ({ data }) => {
    await db.insert(todosTable).values({ title: data.title });
  });

クライアント側のコード

次に Server Functions を呼び出す側の、クライアントサイドのコードを見てましょう。

一覧表示

app/routes/todos/index.tsxというファイルがあります。これは、Todo 一覧を表示するページです。

TanStack Start はDirectory Routesに対応しています(Flat Routesにすることも可能です)。

そのため、app/routes/以下に階層構造を作れば、それが URL に対応します。app/routes/todos/index.tsxの場合は/todosにアクセスすると読み込めます。

それでは具体的なコードを見ていきましょう。

import { createFileRoute } from "@tanstack/react-router";
import { getTodos, createTodo } from "~/functions/todoServer";

import { useRouter } from "@tanstack/react-router";

export const Route = createFileRoute("/todos/")({
  loader: async () => {
    const todos = await getTodos();
    return { todos };
  },

  component: TodosIndex,
});

function TodosIndex() {
  const { todos } = Route.useLoaderData();
  const router = useRouter();

  async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = e.currentTarget;
    const formData = new FormData(form);
    const title = formData.get("title");

    if (typeof title !== "string" || !title.trim()) {
      alert("Title is required!");
      return;
    }

    try {
      await createTodo({
        data: { title: title },
      });
      form.reset();
      router.invalidate();
    } catch (err) {
      alert(String(err));
    }
  }

  return (
    <div style={{ padding: "1rem" }}>
      <h1>Todo List</h1>

      <form onSubmit={handleCreate}>
        <input type="text" name="title" placeholder="New Todo" />
        <button type="submit">Create</button>
      </form>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <a href={`/todos/${todo.id}`}>
              {todo.completed ? <s>{todo.title}</s> : todo.title}
            </a>
          </li>
        ))}
      </ul>
    </div>
  );
}

まず、createFileRoute()を使って、/todosへとアクセスされたときの挙動を制御します。

https://tanstack.com/start/latest/docs/framework/react/learn-the-basics#routes

ポイントはリンク先の次の部分です。

  • Critical data fetching is coordinated from a Route's loader

つまり、createFileRoute()の中でloaderとして関数を読み込み、そこから取得したデータを元に画面へと表示します。

ここで、Server Functions のgetTodosを読み込みます。

  loader: async () => {
    const todos = await getTodos();
    return { todos };
  },

todos の型を見てみましょう。

Drizzle で定義した型が無事に取得されています!

createFileRoute()の続きを読むと、component に、TodosIndex を渡しています。

component: TodosIndex,

これはすぐ直下にある TodosIndex コンポーネントを示しています。

function TodosIndex() {
  const { todos } = Route.useLoaderData();

  // 略

そして Route.useLoaderData() を使って todos を取得し、それを画面上に表示しています。

<ul>
  {todos.map((todo) => (
    <li key={todo.id}>
      <a href={`/todos/${todo.id}`}>
        {todo.completed ? <s>{todo.title}</s> : todo.title}
      </a>
    </li>
  ))}
</ul>

表示部分のロジックとしては、完了しているかどうかで、タイトルに抹線を引くかどうかを変えています。

Todo の作成

次に新しい Todo を作成する、Create ボタンの動きを追いかけましょう。

まず onSubmithandleCreate を呼び出しています。

<form onSubmit={handleCreate}>
  <input type="text" name="title" placeholder="New Todo" />
  <button type="submit">Create</button>
</form>

handleCreate を見てみましょう。

async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
  e.preventDefault();
  const form = e.currentTarget;
  const formData = new FormData(form);
  const title = formData.get("title");

  if (typeof title !== "string" || !title.trim()) {
    alert("Title is required!");
    return;
  }

  try {
    await createTodo({
      data: { title: title },
    });
    form.reset();
    router.invalidate();
  } catch (err) {
    alert(String(err));
  }
}

画面が勝手にリロードしないように preventDefault をして、form 要素を取得、FormData オブジェクトを作成します(FormData オブジェクトによって、値を取り出しやすくなります)。

その次の if 分では formtitle が String 型であり、文字が入力されていることを念の為確認しています( Narrowing をしています)。

そして、try~catch 文の中でcreateTodo()を呼び出しています。

ここで data として { title: title } を渡します。ここで、もし title が文字列以外になっていると、createServerFnで定義した.validatorに違反することになるため、型エラーが表示されます。

例えば、先程の if 文がないと次のような型エラーが表示されます。

型安全にデータを渡せることを確認できました!

あとは、createTodo()を実行して、新しいレコードを DB 上に追加します。

最後にrouter.invalidate()をしています。これが地味に大事で、TanStack Router の invalidate() メソッドを呼び出し、このルートに紐づく loader を再実行しています。

https://tanstack.com/router/v1/docs/framework/react/guide/data-mutations#invalidating-tanstack-router-after-a-mutation

これによって、再度 getTodos() などのデータ取得が走り、最新の Todo リストへと画面が更新されます。

(もしこの invalidate() を使っていないと、画面を再読み込みする必要があり、不便になってしまいます。)

まとめ

公式ドキュメントやソースコードを読みつつ、調査しながらまとめました。🔍️

TanStack Start と Drizzle ORM の組み合わせの魅力について解説できたと思います。

夜なべして書いたので(?)、もしかしたら記事の内容に誤りがあるかもしれません。お気軽にコメント等でお知らせください!

リポジトリも公開しているので、お気軽にご活用ください 💖

https://github.com/lef237/tanstack-drizzle?t=1

Discussion