🐃

Turso と Next.js での開発体験がとても良かった話

2025/02/27に公開

Turso + Next.js(Cloud Run) 構成の良さ

とにかく開発が シンプル・高速・低コスト・フルマネージド で行えます。
CloudSQLは最低でも月3000円ほどかかるため、
複数のSaaSをミニマムに作って始める用途にはコストが大きめです。

Turso の無料枠 + NextJS を GCP CloudRun にデプロイして、
Next の API Routesを使ってバックエンドも実装することでSaaSをモノレポで完結させられ、
デプロイ自体はCloudSQLのアクセス付与やセッティングなしでCloud Runへのデプロイだけで公開できます。

Turso とは

https://turso.tech/

SQLite(libSQL) ベースのクラウドDBサービスです。

  • 低コスト:無料枠が大きく、拡大した場合の追加料金も安め
  • 低レイテンシ:Tursoは分散型のアーキテクチャを採用しており、Cloudflare D1より速い
  • スケーラビリティ:オートスケールに対応しているため、CloudRunとの相性が◎

無料枠では、下記が可能です

  • 容量:9GB
  • データベース数:500
  • 10億回/月の読み取り
  • 2500万回/月の書き込み

Turso CLI

Tursoには便利なCLIが用意されています。
非常に気軽にDBを作成することができ、アクセス時はURLとトークンのみで済みます。
またそのトークンもCLIで簡単に作成・取得することができます。

# CLIインストール
$ brew install tursodatabase/tap/turso

# ブラウザに飛んでログイン
$ turso auth login

# DBの作成
$ turso create db <DB名>

# DBのURL取得
$ turso db show <DB名>

# DBのトークン取得
$ turso db tokens create <DB名>

そしてdrizzle-ormを使ったDBへのアクセスは下記の定義だけで済んでしまいます。

  • DBへのURL
  • DBへのアクセストークン
db.ts
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';
import * as schema from "./schema";

const url = process.env.NEXT_PUBLIC_DATABASE_URL ?? process.env.DATABASE_URL ?? 'libsql://sqlite.db';
const token = process.env.DATABASE_TOKEN;
const client = createClient({
  url: url,
  authToken: token
});

export const db = drizzle(client, { schema });

認証について

Tursoのデメリットとしては Supabase のように認証基盤が用意されていないことです。
そのためログイン機能は Auth.js(旧NextAuth) を使って実装する必要があります。
下記に drizzle-orm + Turso + Auth.js でのサンプルを貼っておきますね!

  • layout.tsx(Auth.jsのためSession Providerで囲む)
  • api/auth/route.ts(Auth.jsログイン認証)
  • db/schema.ts(テーブル定義)
layout.tsx
'use client'
import { SessionProvider } from "next-auth/react";
import "./globals.css";

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body>
        <SessionProvider>
          {children}
        </SessionProvider>
        <ToastContainer aria-label="Toast Notifications" />
      </body>
    </html>
  );
}
login/page.tsx
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";

export default function LoginPage() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [error, setError] = useState("");

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    try {
      const result = signIn("credentials", {
        email: email,
        password: password,
        redirect: false,
      });

      result.then((res) => {
        if (res?.error) {
          setError(res.error);
        } else {
          window.location.href = "/mypage";
        }
      }).catch((error) => {
        console.error(error);
        setError("An unexpected error occurred.");
      });
    } catch (error: any) {
      console.error(error);
      setError(error.message);
    }
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100">
      <div className="bg-white shadow-md rounded px-8 pt-6 pb-8 mb-4">
        <h1 className="block text-gray-700 text-center text-2xl font-bold mb-4">
          Login
        </h1>
        {error && <p className="text-red-500 text-sm italic">{error}</p>}
        <form onSubmit={handleSubmit}>
          <div className="mb-4">
            <label
              className="block text-gray-700 text-sm font-bold mb-2"
              htmlFor="email"
            >
              Email
            </label>
            <input
              className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
              id="email"
              type="email"
              placeholder="Email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
            />
          </div>
          <div className="mb-6">
            <label
              className="block text-gray-700 text-sm font-bold mb-2"
              htmlFor="password"
            >
              Password
            </label>
            <input
              className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline"
              id="password"
              type="password"
              placeholder="Password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
            />
          </div>
          <div className="flex items-center justify-between">
            <button
              className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
              type="submit"
            >
              Login
            </button>
          </div>
        </form>
      </div>
    </div>
  );
}
api/auth/route.ts
import NextAuth from "next-auth";
import type { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/db/db";
import { z } from "zod";
import { compareSync } from "bcryptjs";
import { users } from "@/db/schema";
import { eq } from "drizzle-orm";

const authOptions: NextAuthOptions = {
  adapter: DrizzleAdapter(db),
  session: {
    strategy: "jwt",
  },
  providers: [
    CredentialsProvider({
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
      },
      async authorize(credentials) {
        const schema = z.object({
          email: z.string().email(),
          password: z.string().min(8),
        });
        const result = schema.safeParse(credentials);
        if (!result.success) return null;

        const { email, password } = result.data;
        const user = await db
          .select()
          .from(users)
          .where(eq(users.email, email))
          .get();
        if (!user) return null;

        const isValidPassword = compareSync(password, user.hashedPassword);
        if (!isValidPassword) return null;

        return {
          id: user.id,
          email: user.email,
          name: user.name ?? "",
        };
      },
    }),
  ],
  pages: {
    signIn: "/login",
  },
  callbacks: {
    jwt: async ({ token, user, session }) => {
      if (user) {
        token.uid = user.id;
        token.email = user.email;
        token.name = user.name;
      }
      return token;
    },
    session: async ({ session, user }) => {
      if (user && session.user) {
        session.user.name = user.name ?? "";
        session.user.email = user.email ?? "";
      }
      return session;
    },
  },
  secret: process.env.NEXTAUTH_SECRET,
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

DBの最低限のスキーマはこちらです。

schema.ts
import { sqliteTable, AnySQLiteColumn, uniqueIndex, text, foreignKey, integer } from "drizzle-orm/sqlite-core"
import { sql } from "drizzle-orm"

export const verificationToken = sqliteTable("verification_token", {
	identifier: text().primaryKey().notNull(),
	token: text().notNull(),
	expires: text().notNull(),
},
(table) => [
	uniqueIndex("verification_token_token_unique").on(table.token),
]);

export const sessions = sqliteTable("sessions", {
	id: text().primaryKey().notNull(),
	sessionToken: text("session_token").notNull(),
	userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" } ),
	expires: text().notNull(),
},
(table) => [
	uniqueIndex("sessions_session_token_unique").on(table.sessionToken),
]);

export const users = sqliteTable("users", {
	id: text().primaryKey().notNull(),
	name: text(),
	email: text(),
	emailVerified: text("email_verified").default("sql`(CURRENT_TIMESTAMP)`"),
	image: text(),
	hashedPassword: text("hashed_password").notNull(),
},
(table) => [
	uniqueIndex("users_email_unique").on(table.email),
]);

最後に

Supabase や PostgreSQL ベースの Neon など便利なクラウドDBが出てきていますが、個人的に使ってみて最も開発体験が良かったのがTursoでした。シンプルかつスケーラブル、ランニングコストが良い構成をお探しの方の一助になれば幸いです

Discussion