🚀

【2025年】爆速でゼロイチ開発するための技術スタック

に公開

はじめに

個人開発やゼロイチの立ち上げに最適な技術スタックを書いていく。
なるべく安上がりな構成を目指す。

TypeScriptをベースに

よっぽどパフォーマンスにこだわりがなければTypeScriptを採用したい。

  • 1つの言語でフルスタック開発可
  • 脳の言語スイッチング不要
  • 型定義を使い回せる
  • トレンドで将来性がある

小〜中規模開発ならこの恩恵はでかいため、フルスタックTypeScriptフレームワークを採用したい。

Next.js or React Router

よっぽどパフォーマンスにこだわりがなければReact Routerを採用したい。
個人的にNext.jsはゼロイチ開発にはオーバースペックだと感じる。
以下のような点からNext.jsの採用は見送りたい。

  • Server/Clientの境界線がわかりづらい
  • Server Components, Actions, Functionsなど機能が多くて複雑
  • キャッシュの挙動を把握しづらい
  • 開発環境でコンパイルが遅い時がある

React RouterのLoader/ActionsはCRUDを爆速で実装できるのが推せるポイント。
こんな感じでスッキリ書ける↓

app/product.tsx
import type { Route } from "./+types/product";
import { fakeDb } from "../db";

// 1.サーバーで事前にデータフェッチ
export async function loader({ params }: Route.LoaderArgs) {
  const product = await fakeDb.getProduct(params.pid);
  return product;
}

// 2.ページをレンダー
export default function Product({
  loaderData,
}: Route.ComponentProps) {
  const { name, description } = loaderData;
  return (
      <Form method="post" style={{ marginTop: "2rem" }}>
        <div>
          <label>
            名前:
            <input name="name" defaultValue={current.name} required />
          </label>
        </div>
        <div>
          <label>
            説明:
            <textarea name="description" defaultValue={current.description} />
          </label>
        </div>
        <button type="submit">更新</button>
      </Form>
  );
}

// 3.フォームsubmit時にDBを更新
export async function action({
  request,
}: Route.ActionArgs) {
  const formData = await request.formData();
  const name = await formData.get("name");
  const description = await formData.get("description");
  const product = await fakeDb.updateProduct({name, description});
  return product;
}

BaaSを採用するか

FirebaseはNoSQLで、柔軟性が足りていないと感じたので見送り。
Supabaseは認証、DBなどが密結合しており、また特有の知識が必要になるため、できれば使いたくない。
ベンダーロックインのリスクもあり、なるべく採用しない方向で検討する。
(実はSupabaseはDBのみ採用することになったのだが後述する)

認証

Better Authを採用する。競合の認証プロバイダーとしてClerkが挙げられるが、

  • ベースの機能が完全無料
  • 認証UIのカスタマイズ性が高い
  • 認証フローもカスタマイズ可
  • DBは自前で用意する前提

これらの点でBetter Authが優れている。
Better AuthはDBを自前で用意する前提のため、DBにアクセスすれば認証データを参照できる。
認証データの操作ができると、テストなどもやりやすい。
(ClerkもダッシュボードからDB接続を設定できるが、ローカルDBだと設定が厄介)

https://www.better-auth.com/

データベース

機能性が高いPostgreSQLを使いたい。
メンテナンスなどの手間を避けたいので、フルマネージドなサーバーレスPostgreSQLを調べてみる。

Supabase DB

無料枠があり、日本リージョンもある。
ただし、あらかじめスキーマが複数あり、特にpublicスキーマはAPIとして外部に晒されるので要注意。
DBだけを使うようにすれば、移行もスムーズにできるので、Supabase DBのみ採用する。

https://supabase.com/docs/guides/database/overview

NeonDB

最近流行りのサーバーレスPostgres。
日本リージョンがないため見送り。

https://neon.tech/

xata

こちらも最近流行りのサーバーレスPostgres。
日本リージョンがないため見送り。

https://xata.io/

TypeScript ORM

王道のPrismaか新進気鋭のDrizzleか、どちらかの2択。
Prismaの方が情報量が多いものの

  • スキーマファイルが.prismaで独特
  • スキーマを更新するたびに型生成が必要

という点が引っかかる。
Drizzleであれば、スキーマもTypeScriptで定義ができ、型生成も不要だ。

↓スキーマの定義サンプル

schema.ts
import { serial, text, pgSchema } from "drizzle-orm/pg-core";

export const mySchema = pgSchema("my_schema");
export const colors = mySchema.enum('colors', ['red', 'green', 'blue']);
export const mySchemaUsers = mySchema.table('users', {
  id: serial('id').primaryKey(),
  name: text('name'),
  color: colors('color').default('red'),
});

SQLライクに書くこともできるため、Drizzleを採用する。
↓サンプル

sample.ts
import { eq, not, sql } from 'drizzle-orm';

await db.select().from(users).where(not(eq(users.id, 42)));
await db.insert(users).values({ name: 'Andrew' });

https://orm.drizzle.team/

フォーム

フォーム周りは結構めんどうなので、最適なライブラリを探したい。
条件は以下にする。

  • React Router対応
  • Zod(バリデーションライブラリ)利用可
  • クライアント・サーバーどちらも対応可

王道のReact Hook Formはクライアントサイドのみ動作する。
React Hook Form(クライアント) + Zod(サーバー)で検証となると、
Zodのエラーハンドリング実装がめんどくさい。

ググってみると、ConformとTanstack Formが
サーバーバリデーションにも対応しているらしい。
この2つを比較してみる。

Tanstack Formの強み

クライアントサイドでの非同期バリデーションに対応
→ バリデーション用のAPIリクエスト削減

<form.Field
  name="age"
  asyncDebounceMs={500}
  validators={{
    onChangeAsync: async ({ value }) => {
      // APIのレスポンスで検証など
    },
  }}
  children={(field) => {
    return <>{/* ... */}</>
  }}
/>

Comformの強み

フォームを素早く作れる関数が提供されている(getInputPropsなど)

const schema = z.object({
  email: z
    .string({ required_error: 'メールアドレスは必須です' })
    .email('有効なメールアドレスを入力してください'),
  message: z
    .string({ required_error: 'メッセージは必須です' })
    .min(10, '10文字以上で入力してください'),
});

export default function ContactForm() {
  const [form, fields] = useForm({
    onValidate({ formData }) {
      return parseWithZod(formData, { schema });
    },
    constraint: getZodConstraint(schema),
    shouldValidate: 'onBlur',
    shouldRevalidate: 'onInput',
  });

  return (
    <form {...getFormProps(form)} method="post">
      <div>
        <label htmlFor={fields.email.id}>メールアドレス</label>
        <input
          {...getInputProps(fields.email, { type: 'email' })}
          key={fields.email.key}
        />
        {fields.email.errors && (
          <div id={fields.email.errorId}>{fields.email.errors}</div>
        )}
      </div>

      <div>
        <label htmlFor={fields.message.id}>メッセージ</label>
        <textarea
          {...getInputProps(fields.message, { type: 'text' })}
          key={fields.message.key}
        />
        {fields.message.errors && (
          <div id={fields.message.errorId}>{fields.message.errors}</div>
        )}
      </div>

      <button type="submit">送信</button>
    </form>
  );
}

比較結果

ざっと見た感じ、どちらもWeb標準に沿うように設計されており、書き方が似ている気がする。
どちらの強みがほしいかで決めると良さそう。
個人的には、非同期バリデーションをあまり使わないのでConformを使いたい。
以下の記事がとても参考になった。

https://zenn.dev/frontendflat/articles/react-conform
https://zenn.dev/gemcook/articles/0dced77271059e#非同期バリデーション
https://tanstack.com/form/latest/docs/framework/react/examples/remix
https://ja.conform.guide/integration/remix

スタイリング

サーバーサイドレンダリングを考慮するとCSS in JSは避けて、クラス名が管理しやすいTailwind CSSを採用したい。
ただ、1からTailwind CSSでスタイリングするのはしんどいので、UIフレームワークを利用したい。

UIフレームワーク

UIフレームワークに求める条件は

  • Tailwind CSSベース
  • アクセシブル
  • アニメーションあり
  • 高いカスタマイズ性
  • バンドルサイズが小さい

これらを全て満たすのがshadcn-uiだ。
コンポーネントのコードをそのままインポートする形なので、カスタマイズがしやすいし、バンドルサイズが大きくならずに済む。

例えば、ボタンを使いたい場合、以下のコマンドでコンポーネントをインストール。

npx shadcn@latest add button

すると、以下のようなbutton.tsxが配置される。

button.tsx
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        outline:
          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

propsやTailwind CSSでカスタマイズすることもできるし、直接コンポーネントファイルをいじってカスタマイズもできる。

https://ui.shadcn.com/

ホスティング

ホスティングサービスには以下を求める。

  • React Router対応可
  • SSR可
  • 運用コストが低い
  • CI/CD対応

Vercel

  • 無料プランあり(商用利用不可)
  • 有料プランは月額20ドルから
  • 各ブランチごとにプレビュー環境を作成
  • モノリポ対応

Vercelはどちらかと言えば中〜大規模アプリに適している気がする。
(小規模開発にはオーバースペックかも)
有料プランは開発メンバー毎に料金が発生するので、ちょっとコスト高い気がする。
無料プランは商用利用不可なので要注意。

https://vercel.com/pricing

Cloudflare Workers

  • 無料枠あり
  • リクエストのCPU処理時間:10ミリ秒以内(無料枠)
  • 有料プランは月額5ドルから
  • コスト低め
  • 要アダプター
  • Node.js環境ではないので、デプロイ設定しんどそう

公式のドキュメントがあまり充実しておらず、情報量が少ない。その割にセットアップがしんどそうな予感。コスト低めなのは魅力的だが、そこまで使いたい気持ちになれないかも。

https://www.cloudflare.com/plans/developer-platform/

Google Cloud Run

  • 無料枠あり
  • コスト低め
  • コンテナアプリケーション用

無料枠があって、かつ価格設定がやさしめ。
コンテナで動かせるのもいいなと思う。
アダプターのインストールも必要なさそうなので、楽できそう。

https://cloud.google.com/run/pricing?hl=ja

ホスティング選定結果

Google Cloud Runを使うのが良さそう。

リンター・フォーマッター

Biome

BiomeはRust製の高速なリンター兼フォーマッターだ。ESLintルールとの互換性もあり、移行もスムーズにできる。ESLint + Prettierの構成だと設定の競合が起きたりするので、Biomeを採用したい。ただ、Biomeにもデメリットがある。型情報を要するルールを全対応していない。そのため、

リンター→Biome + typescript-eslint
フォーマッター→Biome

でいきたい。
なお、Biomeは2025年のマイルストーンとして、型情報を要するルールへの全対応を掲げている。
対応が出来次第、typescript-eslintを剥がしたい。

https://biomejs.dev/ja/

Discussion