メインコンテンツへスキップ

Viteで動くCloudflareフルスタック基盤「Void」を試してみた

Cloudflare発のフルスタック基盤「Void」を検証。React・D1・Better Authで認証付きメモアプリを作りながら、Viteベースの開発体験やNext.jsとの違い、Cloudflareへの依存関係とポータビリティの現実を整理します。

Cloudflareから発表された「Void」を使い、メールアドレスとパスワードによる認証機能を備えたメモアプリを作成してみました。プロジェクトの初期化から、データベース設計、サーバーサイド処理、ReactによるUI構築、APIルートの作成、本番ビルドのローカルプレビューまでを一通り試しています。

触る前は「Next.jsに似た構成で、TanStack StartのようなWorkers開発体験に認証・DB・AIを組み合わせたもの」というイメージを抱いていました。しかし実際に調べてみると、Voidには独自のPagesルーティングが備わっている一方で、TanStack Start等の外部フレームワークと組み合わせて使える柔軟性もあります。さらに、データベースや認証の設定だけでなく、Cloudflareのリソース準備(プロビジョニング)やデプロイまでワンストップで扱える点に大きな特徴がありました。

実際に触ってみて感じたのは、Cloudflare上でデータベースや認証、デプロイ設定をひとまとめに完結できる手軽さがある一方、内蔵サービスを深く使うほど他プラットフォームへの移行コストが高くなる、というトレードオフです。

この記事では、まず実際に動作するメモアプリを作りながらVoidの基本設計と開発の流れを解説します。その上で、Next.jsとの設計思想の違いや、Cloudflareへの依存関係がどこに生じるのかを客観的に整理していきます。

検証日:2026年10月16日(日本時間)。Cloudflareの発表記事(公開日:2026年10月15日)をもとに検証しています。今回の検証範囲は、ローカル開発サーバーの起動、本番ビルド、そのローカルプレビュー、およびNode.js環境での最小構成の動作確認です。Cloudflare実環境への本番デプロイ、外部データベースへの接続、および実際のAI推論処理は行っていません。これら未実施の項目については、公式ドキュメントに基づいて解説しています。Cloudflareの発表記事

Voidの基本構成:SDK・CLI・Platformの3本柱

Voidは大きく分けて、以下の3つの要素で構成されています。

  1. Void SDK :ViteをベースとしたフルスタックWeb開発用SDK
  2. Void CLI :Cloudflareリソースのプロビジョニングやデプロイを自動化するCLIツール
  3. Void Platform :チーム開発向けのセルフホスト可能なデプロイ管理基盤

自身のCloudflareアカウントへ直接デプロイすることも可能なため、チーム向けのPlatformの導入が必須というわけではありません。Voidの概要

今回使用するのは、Void独自のルーティング機構で画面を構築する「Pages Routing」です。React向けのアダプターとして @void/react を利用しますが、React以外にもVue、Svelte、Solid用のアダプターが提供されています。Pages Routingの概要

Voidがどのような既存技術の上に成り立っているかは、プロジェクト初期化時にインストールされた依存パッケージを確認するとよく分かります。

役割

検証時の構成バージョン

開発・ビルド

Vite 8.3.4(初期化時にVite+も選択可能)

UI

React 19.3.1、@void/react 1.1.11

サーバーサイド

Hono 4.13.13

ORM

Drizzle ORM 0.45.4

認証

Better Auth 1.7.10

バリデーション

Zod 4.6.7(手動で追加)

Void本体

void 1.1.11

上記は検証プロジェクトにおける実測値です。Voidは車輪の再発明をするのではなく、定評のある既存ライブラリを束ね、接続設定、型生成、デプロイ規約を整理したツールチェーンであることが分かります。なお、依存ツリーを確認した限りでは、Vercel AI SDKの ai パッケージは含まれていませんでした。少なくとも現行バージョン(1.1.11)において、Vercel AI SDKが内包されているわけではない点には注意が必要です。npm: void 1.1.11

開発環境の準備とプロジェクトの初期化

Void 1.1.11を動かすには、 Node.js 24.21.0以上 が必須となります。手元の環境が24.14.1だったため、今回は要件を満たす24.21.0を別途用意して検証しました。

node --version
npm --version

今回の検証環境は、Node.js 24.21.0、npm 11.19.0(Windows環境)です。以降の手順は、要件を満たすNode.jsがPATHに通っている前提で進めます。

まずは作業用ディレクトリを作成し、Voidをインストールして初期化コマンドを実行します。

mkdir void-notes
cd void-notes
npm install -D void@1.1.11
npx void init --no-git

--no-git フラグは、検証フォルダでGitの初期化をスキップするために付与しています。通常のプロジェクトであれば npx void init だけで問題ありません。バージョンを明示しているのは、本記事と同じ環境を再現しやすくするためです。クイックスタート、CLIリファレンス

初期化ウィザードでは、以下の設定を選択しました。

項目

選択内容

Pages scaffold toolchain

Vite

Pages framework

React

Pages starter

D1 (SQLite)

デプロイ設定

Skip deployment setup

デフォルトのツールチェーンはVite+ですが、今回は基本動作を把握しやすいよう標準のViteを選択しています。後述する npm run dev や npm run build も、この選択に基づいて生成されたスクリプトです。

初期化が完了すると、フロントエンドの画面だけでなく、データベーススキーマ、マイグレーションファイル、サンプルのAPIルートまで一通り生成されます。

void-notes/
├── pages/
│   ├── index.tsx
│   └── index.server.ts
├── routes/api/hello.ts
├── db/
│   ├── schema.ts
│   └── migrations/
├── vite.config.ts
├── void.config.ts
├── tsconfig.json
├── AGENTS.md
└── package.json

AIコーディングエージェント向けの AGENTS.md が自動生成され、パッケージ内のローカルドキュメント(node_modules/void/docs/)を参照するよう案内が書かれているのもユニークな点です。公式Webサイトのドキュメントと手元のバージョンの挙動が異なる場合でも、同梱されているドキュメントを直接参照できます。

開発サーバーを起動してみます。

npm run dev

ブラウザで http://localhost:5173/ にアクセスすると、データベース連携済みのメッセージ投稿フォームが表示されます。また、スターターに含まれる /api/hello からは以下のJSONレスポンスが返ってきました。

{
  "message": "Hello from Void",
  "framework": "React",
  "database": "D1"
}

ここまでの手順では、CloudflareへのログインやリモートでのD1データベース作成は一切不要です。ローカル環境だけでD1を使った開発をすぐに始められるのが手軽ですね。なお、後述するWorkers AIについては、ローカルのみで完結しない点に注意が必要です。

Viteプラグインによるフロントエンドとバックエンドの統合

プロジェクト直下の vite.config.ts には、Void本体のプラグインとReactアダプターのプラグインが設定されています。以下は生成された設定ファイルの内容です。

import { defineConfig } from "vite";
import { voidPlugin } from "void";
import { voidReact } from "@void/react/plugin";

export default defineConfig({
  plugins: [
    voidPlugin(),
    voidReact({ react: { compiler: true } }),
  ],
});

voidPlugin() がVoidのファイル規約やCloudflareリソースの推論を担当し、voidReact() がReactコンポーネントの処理を行います。今回のスターターでは、デフォルトでReact Compilerも有効になっていました。

画面表示とサーバーサイドの処理は、以下のファイル構成で定義します。

pages/index.tsx         → ルート(/)のReactコンポーネント
pages/index.server.ts   → ルート(/)のloaderおよびaction
routes/api/notes/       → REST API(JSONエンドポイント)

loader はページ描画に必要なデータを取得する関数であり、action はフォーム送信などのデータ更新を受け持つ関数です。初回アクセス時はサーバー側でHTMLを生成(SSR)し、その後のページ遷移やフォーム送信時には必要なデータのみを取得・更新して画面に反映します。公式ドキュメントによると、このPagesアーキテクチャはInertia.jsの設計思想から着想を得ているとのことです。Loaders & Props、Actions & Forms

ファイル配置だけを見るとNext.jsのPages RouterやApp Routerに似ていますが、Void Pagesの本質は「サーバー側でデータを取得し、それを標準的なUIコンポーネントにPropsとして渡す」という極めて明快なデータフローにあります。

データベーススキーマの定義とバリデーションの実装

ここからは、初期状態のスターターを「ユーザー認証付きのメモアプリ」に作り替えていきます。実装する機能は、ユーザー登録、ログイン、ログイン中ユーザーのメモ一覧表示・追加・削除の3つです。

まずは入力バリデーションを行うため、Zodをインストールします。

npm install zod@4.6.7

続いて、db/schema.ts を以下のように編集します。

import { integer, sqliteTable, text } from 'void/schema-d1';
import { sql } from 'void/db';
import { z } from 'zod';

// スターターのテーブルを残し、追加差分だけのマイグレーションにする
export const messages = sqliteTable('messages', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  text: text('text').notNull(),
  createdAt: text('created_at').notNull().default(sql`(datetime('now'))`),
});

export const notes = sqliteTable('notes', {
  id: integer('id').primaryKey({ autoIncrement: true }),
  userId: text('user_id').notNull(),
  title: text('title').notNull(),
  body: text('body').notNull(),
});

// userIdはクライアントから受け取らず、サーバー側で認証セッションから取得する
export const noteInput = z.object({
  title: z.string().trim().min(1, 'タイトルを入力してください').max(80),
  body: z.string().trim().min(1, '本文を入力してください').max(2000),
});

テーブル定義に使う sqliteTable などは、void/schema-d1 からインポートします。Drizzle ORMのスキーマ定義を、Voidが提供するラッパーAPI経由で記述する形式です。データ操作時も void/db から db インスタンスやヘルパー関数(eq, desc など)を直接利用できるため、プロジェクト側で drizzle-orm を明示的にインストールする必要はありませんでした。Databaseガイド

先頭にある messages は、スターターに含まれていたテーブルです。これを削除せずに残している理由については、後述のマイグレーションの節で解説します。

設計上のポイントとして、notes テーブルの userId カラムは必須ですが、バリデーションスキーマの noteInput には含めていません。ユーザーIDはクライアントから送信された値を信用するのではなく、サーバー側の認証セッションから直接取得して設定するためです。

なお、VoidにはDrizzleテーブルからZodスキーマを自動生成する void/drizzle-zod も用意されています。今回は「クライアントが送信できるのはタイトルと本文のみ」という境界を明確にするため、Zodで手動定義しました。データベースの型定義と、HTTP入力に対する実行時バリデーションの役割を明確に分ける設計にしています。

認証機能の有効化とデータ処理(loader / action)の実装

プロジェクト設定ファイル void.config.ts を以下のように変更します。worker.compatibility_date は、初期化時に生成された日付をそのまま使用しています。

import { defineConfig } from 'void/config';

export default defineConfig({
  worker: { compatibility_date: '2026-02-24' },
  auth: { providers: ['email'] },
  head: {
    titleTemplate: '%s | Void Notes',
    htmlAttrs: { lang: 'ja' },
    meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
  },
});

auth.providers に email を指定するだけで、メールアドレスとパスワードによる認証が有効になります。Voidの内部でBetter Authが構成され、データベース接続、認証用APIエンドポイント、クライアントSDKが自動的にセットアップされます。認証に必要なユーザー情報やセッション情報のテーブルも、アプリケーションと同じデータベース内に作成されます。Authenticationガイド

次に、ページ側のデータ取得と更新処理を担う pages/index.server.ts を作成します。

import { notes, noteInput } from '@schema';
import { defineHandler, type InferProps } from 'void';
import { getUser, requireAuth } from 'void/auth';
import { db, desc, eq } from 'void/db';

export const loader = defineHandler(async () => {
  const user = getUser();
  return {
    user: user ? { id: user.id, name: user.name } : null,
    notes: user
      ? await db.select().from(notes).where(eq(notes.userId, user.id)).orderBy(desc(notes.id))
      : [],
  };
});

export type Props = InferProps<typeof loader>;

export const action = defineHandler.withValidator({ body: noteInput })(async (c, { body }) => {
  const user = requireAuth(c);
  await db.insert(notes).values({ ...body, userId: user.id });
});

このコードでは、主に以下の3つの処理を行っています。

  1. getUser() で認証状態を取得し、未ログインなら null を返す
  2. ログイン済みであれば、そのユーザー本人のメモのみをDBから取得する
  3. メモの作成時(action)は requireAuth(c) で認証を必須とし、セッションから取得したユーザーIDを紐付けて保存する

UI側で表示を制御するだけでは、APIやフォームへの直接リクエストによる不正なデータ登録を防げません。そのため、サーバー側の処理でも必ず認証状態を確認し、データ取得・保存の両面でユーザーごとのアクセス制御を明示的に記述しています。

また、InferProps<typeof loader> を使うことで、loader が返すデータの型からUIコンポーネントのProps型を自動推論できます。データベーススキーマからクエリ結果、サーバーハンドラ、Reactコンポーネントに至るまで型が一貫して繋がり、手作業で型定義を重複管理する必要がありません。型安全性のガイド

UI側からは import type { Props } のように型のみをインポートするため、サーバーサイドのコードやDBクエリのロジックがブラウザ側のバンドルに含まれてしまう心配もありません。なお、loader の戻り値はクライアントにシリアライズされて送信されるため、今回はユーザー情報として id と name のみに絞って返却しています。

Reactコンポーネントの実装とフォームハンドリング

メイン画面となる pages/index.tsx を実装します。後ほど作成する削除APIの呼び出しやログアウト処理もここで組み込んでいます。 ※このコードを動かすにはデータベースへのマイグレーション適用が必要なため、動作確認は後述のマイグレーション手順を実行してから行ってください。

import { Form, Link, useForm, useRouter } from '@void/react';
import { useState } from 'react';
import { auth, fetch } from 'void/client';
import type { Props } from './index.server';
import './style.css';

export default function NotesPage({ user, notes }: Props) {
  const form = useForm('/', { title: '', body: '' });
  const router = useRouter();
  const [error, setError] = useState('');
  const [deletingId, setDeletingId] = useState<number | null>(null);

  async function removeNote(id: number) {
    setError('');
    setDeletingId(id);
    try {
      await fetch('/api/notes/:id', { method: 'DELETE', params: { id: String(id) } });
      router.refresh();
    } catch {
      setError('削除できませんでした。もう一度お試しください。');
    } finally {
      setDeletingId(null);
    }
  }

  async function signOut() {
    const result = await auth.signOut();
    if (result.error) {
      setError(result.error.message ?? 'ログアウトできませんでした。');
      return;
    }
    router.refresh();
  }

  return (
    <main>
      <header>
        <p className="eyebrow">VOID / REACT / D1 / BETTER AUTH</p>
        <h1>自分用メモ帳</h1>
        <p>調べたこと、気づいたことを、一枚ずつ。</p>
      </header>
      {error && <p role="alert">{error}</p>}
      {user ? (
        <>
          <nav><span>{user.name} さんのメモ</span><button onClick={signOut}>ログアウト</button></nav>
          <section aria-labelledby="new-note">
            <h2 id="new-note">メモを追加</h2>
            <Form form={form}>
              <label htmlFor="title">タイトル</label>
              <input id="title" name="title" maxLength={80} value={form.data.title}
                onChange={(event) => form.setData('title', event.target.value)} />
              {form.errors.title && <p role="alert">{form.errors.title}</p>}
              <label htmlFor="body">本文</label>
              <textarea id="body" name="body" maxLength={2000} rows={4} value={form.data.body}
                onChange={(event) => form.setData('body', event.target.value)} />
              {form.errors.body && <p role="alert">{form.errors.body}</p>}
              <button disabled={form.pending}>{form.pending ? '保存中…' : '保存する'}</button>
              {form.wasSuccessful && <span role="status"> 保存しました</span>}
            </Form>
          </section>
          <section aria-labelledby="saved-notes">
            <h2 id="saved-notes">保存したメモ <small>{notes.length} 件</small></h2>
            {notes.length === 0 && <p>まだメモはありません。</p>}
            {notes.map((note) => (
              <article key={note.id}>
                <h3>{note.title}</h3><p className="note-body">{note.body}</p>
                <button disabled={deletingId === note.id} onClick={() => removeNote(note.id)}
                  aria-label={`${note.title}を削除`}>{deletingId === note.id ? '削除中…' : '削除'}</button>
              </article>
            ))}
          </section>
        </>
      ) : (
        <section><h2>メモを自分のアカウントに保存</h2>
          <p>メールアドレスとパスワードで登録できます。</p>
          <Link href="/login">ログイン・新規登録へ</Link>
        </section>
      )}
    </main>
  );
}

この画面実装で特に重要なのが、以下の1行です。

const form = useForm('/', { title: '', body: '' });

パス / に対応するサーバー側 action で定義されたバリデーションスキーマ(noteInput)から、フォームデータやエラーメッセージのフィールド名に自動で型が付きます。このフォームオブジェクトを <Form form={form}> に渡すことで、送信中状態(form.pending)や各項目のエラー表示(form.errors.title など)を直感的に扱えます。action が正常に終了すると自動的に loader が再実行され、最新のメモ一覧へと画面が更新されます。

マイグレーション適用後に動作確認したところ、空欄で保存しようとした場合はタイトルや本文のエラーメッセージが表示され、正常に入力したメモはその場で一覧に追加されました。ブラウザをリロードしても保存したメモが問題なく保持されていることを確認しています。

なお、Pagesにおけるフォーム送信時のエラーハンドリングには独特の仕様がありました。サーバー側でバリデーションエラーが発生した場合、400番台のエラーレスポンスを返すのではなく、エラー情報を含んだページデータとしてHTTPステータス200でレスポンスが返されます(通常のAPIエンドポイントでは400エラーになります)。そのため、HTTPステータスコードで成否を判別するのではなく、form.errors や form.error プロパティを参照してUIにエラーを表示する設計となっています。

画面のスタイリングは pages/style.css で行っています。デザインは本題ではないため、最小限の表示であれば以下のスタイルで動作します。

:root { font: 16px/1.7 system-ui, sans-serif; color: #20352d; background: #f5f4ed; }
main { max-width: 780px; margin: 48px auto; padding: 0 24px; }
section { margin: 24px 0; padding: 24px; background: white; border: 1px solid #ddd; }
label { display: block; margin-top: 12px; }
input, textarea { box-sizing: border-box; width: 100%; font: inherit; padding: 8px; }
button { margin: 12px 0; padding: 8px 16px; cursor: pointer; }
.note-body { white-space: pre-wrap; overflow-wrap: anywhere; }
[role=alert] { color: #a92e26; }

認証画面(ログイン・新規登録)の実装

続いて、ログインおよびユーザー登録画面となる pages/login.tsx を追加します。

import { Link, useRouter } from '@void/react';
import { useState, type FormEvent } from 'react';
import { auth } from 'void/client';
import './style.css';

export default function LoginPage() {
  const router = useRouter();
  const [mode, setMode] = useState<'sign-in' | 'sign-up'>('sign-in');
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const [pending, setPending] = useState(false);

  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    setPending(true);
    setError('');
    try {
      const result = mode === 'sign-up'
        ? await auth.signUp.email({ name, email, password })
        : await auth.signIn.email({ email, password });
      if (result.error) {
        setError(result.error.message ?? '認証に失敗しました。');
      } else {
        router.visit('/');
      }
    } catch {
      setError('接続できませんでした。もう一度お試しください。');
    } finally {
      setPending(false);
    }
  }

  return (
    <main className="login">
      <p className="eyebrow">VOID NOTES</p>
      <h1>{mode === 'sign-up' ? 'アカウントを作成' : 'ログイン'}</h1>
      <form onSubmit={submit}>
        {mode === 'sign-up' && <><label htmlFor="name">名前</label>
          <input id="name" required autoComplete="name" value={name} onChange={(e) => setName(e.target.value)} /></>}
        <label htmlFor="email">メールアドレス</label>
        <input id="email" type="email" required autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} />
        <label htmlFor="password">パスワード(8文字以上)</label>
        <input id="password" type="password" required minLength={8}
          autoComplete={mode === 'sign-up' ? 'new-password' : 'current-password'}
          value={password} onChange={(e) => setPassword(e.target.value)} />
        {error && <p role="alert">{error}</p>}
        <button disabled={pending}>{pending ? '送信中…' : mode === 'sign-up' ? '登録する' : 'ログインする'}</button>
      </form>
      <p><button className="text-button" onClick={() => { setMode(mode === 'sign-in' ? 'sign-up' : 'sign-in'); setError(''); }}>
        {mode === 'sign-in' ? '新規登録はこちら' : 'ログインに戻る'}</button></p>
      <Link href="/">メモ帳に戻る</Link>
    </main>
  );
}

認証処理には、void/client から提供される auth.signUp.email()、auth.signIn.email()、およびメモ帳画面で使った auth.signOut() をそのまま呼び出しています。UIコンポーネント自体は自作する必要がありますが、認証APIの呼び出しは非常にシンプルです。

独自の認証プロトコルを一から学ぶ必要はなく、「Voidにあらかじめ設定されたBetter Authをそのまま利用する」という感覚で開発できます。さらに高度な設定が必要な場合は、プロジェクトルートに auth.ts を配置して defineAuth() を呼び出すことで、デフォルトの挙動をカスタマイズできます。メール送信機能やOAuthプロバイダー連携などは、必要に応じて追加の設定を行います。

※今回の実装例は最小限の検証用であるため、実運用で求められるメールアドレス確認やパスワードリセット、アカウント削除などの機能は省略しています。

APIルートの作成とアクセス制御

フロントエンドとは独立したREST APIとして、メモ一覧を取得する routes/api/notes/index.ts を作成します。

import { notes } from '@schema';
import { defineHandler } from 'void';
import { requireAuth } from 'void/auth';
import { db, desc, eq } from 'void/db';

export const GET = defineHandler(async (c) => {
  const user = requireAuth(c);
  return db.select().from(notes).where(eq(notes.userId, user.id)).orderBy(desc(notes.id));
});

次に、特定のメモを削除する動的ルート routes/api/notes/[id].ts を作成します。

import { notes } from '@schema';
import { defineHandler } from 'void';
import { requireAuth } from 'void/auth';
import { and, db, eq } from 'void/db';
import { z } from 'zod';

export const DELETE = defineHandler.withValidator({
  params: z.object({ id: z.string().regex(/^[1-9]\d*$/) }),
})(async (c, { params }) => {
  const user = requireAuth(c);
  const deleted = await db.delete(notes)
    .where(and(eq(notes.id, Number(params.id)), eq(notes.userId, user.id)))
    .returning({ id: notes.id });
  if (deleted.length === 0) return c.json({ error: 'Not found' }, 404);
  return { deleted: true };
});

defineHandler() のハンドラ関数に渡される引数 c は、Honoのコンテキストオブジェクトです。ハンドラからJavaScriptオブジェクトを返せば自動的にJSONレスポンスになり、ステータスコードやヘッダーを明示したい場合は c.json() などを利用できます。Server Routingガイド

削除処理のSQL条件には、メモのID(notes.id)だけでなく、ログイン中ユーザーのID(notes.userId)も含めています。これにより、仮に他人のメモIDが分かっても勝手に削除される事故を防ぐことができます。該当するメモが存在しない場合も、自分以外のメモである場合も、等しく404エラーを返すように実装しています。

また、メイン画面側から削除APIを呼び出す際は、型安全なクライアントである void/client の fetch を使用しています。

await fetch('/api/notes/:id', {
  method: 'DELETE',
  params: { id: String(id) },
});

ファイルシステム上は [id] というディレクトリ名ですが、クライアントから呼び出す際は :id というプレースホルダー記法を用います。URLパス、HTTPメソッド、リクエストパラメータ、レスポンスの型が自動生成されたルーティング型に基づいてチェックされるため、スペルミスやパラメータの渡し間違いをビルド時に防げます。Typed Fetchガイド

データベースマイグレーションと型定義の生成

スキーマ定義と認証設定が完了したら、SQLマイグレーションファイルを生成してローカルデータベースに適用します。

npx void db generate
npx void db migrate
npx void prepare
npx tsc --noEmit
npm run dev

各コマンドの役割は以下の通りです。

  • npx void db generate:前回のスナップショットとの差分を検出し、マイグレーションSQLを生成します。
  • npx void db migrate:未適用のSQLマイグレーションをローカルデータベースに実行します。
  • npx void prepare:開発サーバーを立ち上げずに、最新のスキーマやルート定義に基づいた型定義ファイルを生成します。

生成されたマイグレーションファイルを確認すると、自分で定義した notes テーブルだけでなく、Better Authが必要とする account、session、user、verification といったテーブルの作成クエリも自動的に含まれていました。これらを手書きで db/schema.ts に書き写したり、別途Better AuthのCLIを実行したりする必要はありません。Database CLIリファレンス

なお、作業中にスターター初期状態の messages テーブルを削除したところ、破壊的変更(テーブル削除)として検知され、非対話環境でのマイグレーションが一時停止しました。今回は安全性を重視して既存テーブルを残す設計に変更し、追加差分のみのSQLを再生成して適用しています。本番運用でも、生成されたSQLの内容を事前に確認してからマイグレーションを実行するのが安心です。

また、Drizzleが「カラムの新規追加かリネームか」を尋ねてくる場合など、ターミナルで対話入力(TTY)が求められるケースがあります。非対話シェルで実行した際、エラーが発生したにもかかわらず末尾に完了メッセージが表示される現象が見られたため、ターミナルのログだけでなく実際のSQLファイルと適用結果を目視で確認することをお勧めします。

最後に、TypeScriptの型チェック(tsc --noEmit)を通すため、CSSの副作用import(import './style.css')に対応する型宣言を補う必要があります。tsconfig.json の compilerOptions.types に vite/client を追加してください。

{
  "compilerOptions": {
    "types": ["void/env", "vite/client"]
  }
}

※上記は該当部分の抜粋です。自動生成された extends や include などの設定はそのまま維持してください。

本番ビルドのローカルプレビューと環境変数の設定

開発サーバー(npm run dev)で動かす段階では、認証シークレットを手動設定しなくても問題なく動作しました。しかし、本番ビルドをローカルプレビュー(npm run preview)したところ、初期状態では Missing Better Auth secret というエラーが発生し、HTTP 500で停止してしまいました。

この問題は、プロジェクトルートに env.ts を作成してシークレットを型定義し、.env ファイルにローカル用のシークレット値を設定して再ビルドすることで解消しました。

import { defineEnv, string } from 'void/env';

export default defineEnv({ BETTER_AUTH_SECRET: string() });

env.ts で BETTER_AUTH_SECRET を必須値として宣言すると、環境変数のバリデーションが有効になります。新規に検証用プロジェクトをセットアップする場合は、以下のコマンドでランダムなシークレット文字列を生成して .env に書き出せます。

node -e "require('node:fs').writeFileSync('.env', 'BETTER_AUTH_SECRET=' + require('node:crypto').randomBytes(32).toString('hex') + '\n', {flag:'wx'})"
npx void prepare
npx tsc --noEmit
npm run build
npm run preview

flag: 'wx' を指定しているため、すでに .env が存在する場合は上書きされずに安全に失敗します。すでにファイルがある場合は、その中に BETTER_AUTH_SECRET を追記してください。

なお、.env はあくまでローカル開発用のファイルです。Cloudflare本番環境に設定する機密情報は、void secret put などのCLIコマンドを使って管理します。ビルド時にローカルプレビュー用の成果物が生成されるため、.env や dist/、.void/ ディレクトリは .gitignore に追加してGitの追跡から除外しておきましょう。環境変数の設定ガイド

アプリケーションの動作確認とテスト検証

実際にブラウザから操作してみた画面がこちらです。ローカル用のアカウントを作成し、自分専用のメモを登録・表示できています。

ブラウザ上では、ユーザー登録、必須入力のバリデーションエラー、メモの登録、ページリロード後のデータ永続化が正常に機能することを確認しました。

さらに、開発サーバーと本番ビルドのローカルプレビューの双方に対して自動テストスクリプトを実行し、以下の11項目について期待通りの挙動を示すことを確認しました。

テスト項目

検証結果

未認証時のページ表示(SSR)

ログインを促すHTMLが正常にサーバーレンダリングされる

未認証でのメモ一覧APIアクセス

401 Unauthorized が返る

ページからメモを保存し、APIで取得

保存したタイトル・本文が一致する

空白タイトルでの送信

Pagesのフィールドバリデーションエラーが返る

認証済みページのキャッシュ制御

Cache-Control: no-store が付与される

他ユーザーのメモ一覧アクセス

自分以外のメモが一覧に含まれない

他ユーザーのメモ削除リクエスト

404 Not Found となり削除されない

外部OriginからのCSRF的リクエスト

認証セッションが無効化され 401 が返る

不正な形式のID指定で削除

400 Bad Request が返る

所有者本人によるメモ削除

200 OK となり正常に削除される

ログアウト後のセッション利用

401 Unauthorized となりアクセス不可

※なお、これはローカル環境における動作検証です。Cloudflareの実環境におけるグローバルな分散実行やレイテンシ、実トラフィック下での耐久性などは今回の検証範囲には含まれていません。

Cloudflareへのデプロイフローとリソース推論

続いて、Cloudflare本番環境へのデプロイ手順について解説します(※本節以降は公式ドキュメントに基づく解説です)。

プロジェクトをCloudflareにデプロイする場合、CLIから以下のコマンドを実行します。

npx void connect --platform cloudflare
npx void deploy --platform cloudflare

ブラウザでCloudflareの認証とアカウント選択を行うと、Void CLIがアプリケーションのビルド、必要なクラウドインフラの自動作成、マイグレーションの実行、そしてWorkersへのデプロイまでを一括して処理してくれます。一度接続を済ませておけば、次回以降は npx void deploy の1コマンドだけで本番反映が完了します。デプロイガイド

この省力化を支えているのが、Voidの リソース推論(Resource Inference) という仕組みです。たとえばコード内で void/db がインポートされていれば「D1データベースが必要である」と自動判別し、ローカルではSQLite、本番環境では対応するD1リソースを自動的にプロビジョニングして接続を確立します。手動で設定ファイルを書く手間が大幅に省ける反面、裏で何が推論されているのかを意識しておく必要があります。Resource Inferenceリファレンス

また、デプロイ先には「開発者個人のCloudflareアカウント」に直接デプロイする方法と、「組織がホストするVoid Platform」を経由してデプロイする方法の2種類が用意されています。後者のVoid Platformを利用すると、チームメンバー一人ひとりにCloudflareのAPIトークンを配布することなく、権限を一括管理しながら安全にデプロイを行えます。Cloudflareの公式発表でも、このチーム向けプラットフォームの利便性が強調されていました。

AI機能の扱い:Workers AIとAI Gatewayの使い分け

VoidにおけるAI機能の連携について、公式ドキュメントと内部実装をもとに整理します(※以下のコードはドキュメントに基づく追加例であり、今回のメモ帳アプリで実際に実行したものではありません)。

CloudflareのサーバーレスGPU上で動くWorkers AIを呼び出す場合、void/ai の ai.run() を使用します。以下はZodによる入力バリデーションを組み合わせたAPIルートの実装例です。

// routes/api/ask.ts(実装例:未実行)
import { defineHandler } from 'void';
import { ai } from 'void/ai';
import { z } from 'zod';

export const POST = defineHandler.withValidator({
  body: z.object({ prompt: z.string().trim().min(1).max(2000) }),
})(async (_c, { body }) => {
  return ai.run('@cf/meta/llama-3.3-70b-instruct-fp8-fast', {
    messages: [{ role: 'user', content: body.prompt }],
  });
});

Workers AIを利用する場合、ローカル開発時であってもCloudflareアカウントに接続しておく必要があります。

npx void connect --platform cloudflare
npm run dev

Workers AIにはローカルのエミュレータ(推論シミュレーター)が存在しないため、ローカル開発中であっても実際にはCloudflare側のリモートAPIが呼び出され、アカウントの利用可能枠を消費します。

また、設定ファイルのリファレンスによると、Cloudflareアカウント上で直接利用する ai.run() や ai.stream()、ai.image() はWorkers AIバインディングを経由するため、AI Gatewayの設定は必須ではありません。一方、OpenAIやAnthropicなどの外部プロバイダーのHTTP APIを直接呼び出す ai.provider(...).fetch(...) を使う場合には、設定済みのAI Gatewayを経由して通信が行われます。AI設定リファレンス

// void.config.ts への追加例(未実行)
ai: { gateway: 'my-gateway' }
// 外部プロバイダーのAPI呼び出し例(未実行)
import { ai } from 'void/ai';

const response = await ai.provider('openai').fetch('/chat/completions', {
  body: {
    model: 'gpt-4o',
    messages: [{ role: 'user', content: 'この文章を要約してください' }],
    max_tokens: 256,
  },
});

この方式では、AI Gatewayの構成や各プロバイダーのAPIキーが必要となります。多様なLLMプロバイダーを扱える設計にはなっていますが、あくまでCloudflareのインフラ基盤(Workers AIやAI Gateway)を通じた抽象化が行われている点に留意する必要があります。Vercel AI SDKの generateText() や useChat() と全く同じインターフェースだと考えると認識にズレが生じます。AI開発ガイド

データベースの選択肢とCloudflare(Hyperdrive)接続

Voidで利用できるデータベースは、D1(SQLite)だけではありません。プロジェクト初期化時にはPostgreSQLやMySQLも選択できるようになっています。

データベース

Voidの設定

Cloudflare本番での接続方式

D1 / SQLite

デフォルト

D1 binding

PostgreSQL

database: 'pg'

Hyperdrive

MySQL

database: 'mysql'

Hyperdrive

たとえばPostgreSQLを利用する場合、void.config.ts に database: 'pg' を指定し、スキーマ定義には void/schema-pg の pgTable などを利用します。設定ファイルを1行変えるだけで既存のSQLiteスキーマがそのままPostgreSQLに変換されるわけではないため、スキーマ定義自体の書き換えが必要です。

import { defineConfig } from 'void/config';

export default defineConfig({ database: 'pg' });
# ローカルの .env に記述する接続文字列の例
DATABASE_URL=postgresql://user:password@host:5432/mydb?sslmode=require

公式ドキュメントによると、開発環境ではPostgreSQLに直接接続し、Cloudflare本番環境では接続プーリングとキャッシュを提供する「Hyperdrive」を経由して通信を行います。データベース自体は外部のマネージドサービス(SupabaseやNeon、AWS RDSなど)を自由に選べますが、Voidの標準的な本番接続パイプラインにはCloudflareの機能が組み込まれています。PostgreSQLガイド、MySQLガイド

Node.js環境での動作検証とポータビリティの現実

「Voidで作ったアプリはCloudflare以外でも動くのか?」というポータビリティ(可搬性)を確かめるため、Node.js向けの最小構成プロジェクトを別途作成して動作を検証しました。

void.config.ts で実行ターゲットをNode.jsに指定します。

import { defineConfig } from 'void/config';

export default defineConfig({ target: 'node' });

Viteの設定に voidPlugin() を含め、Node.js用サーバーとして @hono/node-server を導入します。APIエンドポイントは、今回のメモアプリと同じ defineHandler() で記述できます。

import { defineHandler } from 'void';

export const GET = defineHandler(() => ({ message: 'Hello from Void on Node.js' }));

プロジェクトをビルドして実行します。

npm install @hono/node-server
npm run build
node dist/ssr/index.js

デフォルトのポート番号は3000ですが、他のローカルプロセスとの競合を避けるため、環境変数 PORT=3197 を指定して起動しました。ブラウザから /api/hello にアクセスすると、期待通り以下のJSONが返却されました。

{ "message": "Hello from Void on Node.js" }

しかし、ここで void/db をインポートするエンドポイントを追加してみたところ、ビルド時に以下のエラーが発生しました。

target: 'void/db' is not available with target 'node'.
CF bindings require target: "cloudflare".

公式ドキュメントにも明記されている通り、Node.js、Bun、Denoなどのランタイムでは、Cloudflareのバインディングに依存する void/db、void/kv、void/storage、void/auth、void/ai、void/env といった組み込みモジュールは使用できません。これらのランタイムで動かす場合、データベースや認証は一般的な外部ライブラリ(通常のDrizzle ORMや自前の認証実装など)を用いて独自に再構成する必要があります。また、void deploy コマンドもこれらのランタイムへの自動デプロイには対応していません。Node.js / Bun / Deno連携ガイド

つまり、「VoidのルーティングやSSRの仕組み自体はNode.jsでも動くが、Voidが提供する強力な組み込みバックエンド機能はCloudflare環境を前提としている」ということです。将来的にCloudflare以外のインフラへ移行する可能性がある場合は、この依存境界を十分に意識して設計する必要があります。

Next.jsやTanStack Startとのアーキテクチャ比較

ディレクトリ構造や命名規則だけを見ると、VoidはNext.jsとよく似ています。ファイルベースのルーティング、サーバー側でのデータ取得、フォーム処理、SSR、そして型安全なAPI呼び出しがシームレスに統合されているためです。

しかし、その内部構造には明確な違いがあります。Next.jsのApp Routerでは、ページやレイアウトがデフォルトで React Server Components (RSC) として動作します。一方、今回検証したVoid Pagesは、.server.ts の loader 関数でデータを取得し、それを標準的なReactページコンポーネントにPropsとして渡す方式です。初回アクセス時はサーバーでHTMLをレンダリング(SSR)し、以降はブラウザ側でも動作するクライアントコンポーネントとして振る舞います。同じ「SSR対応」であっても、RSCを前提としたアーキテクチャとは根本的に異なります。Next.js Server and Client Components

また、TanStack Startとの関係についても、「VoidがTanStack Startを取り込んだ」というわけではありません。公式にサポートされているのは、 VoidとTanStack Startを組み合わせて利用する併用構成 です。Viteの設定において voidPlugin() の後ろにTanStack Startのプラグインを配置することで、画面描画やルーティングはTanStack Startに任せつつ、リソース管理やCloudflareへのデプロイをVoidに担当させるという住み分けが可能です。TanStack Start統合ガイド

注意点として、現時点のTanStack Start併用モードでは、Voidが管理する認証機能(void/auth)は利用できず、Better AuthをTanStack Start側に直接組み込むよう推奨されています。今回作成したメモアプリのように void/auth の手軽さをそのまま持ち込めるわけではない点には留意が必要です。

比較項目

Next.js(App Router)

Void Pages(今回検証)

TanStack Start + Void

画面の基本構造

RSC + Client Components

loader + UIコンポーネント

TanStack Startのコンポーネント構造

ルーティング

Next.js独自

Void Pages

TanStack Router

データベース接続

アプリ側で個別に選定・実装

VoidのDB APIを利用可能

VoidのDB APIを利用可能

認証の統合

個別に選定・実装

void/auth(設定のみで利用可能)

現時点では非対応(自前で統合)

主なデプロイ先

Vercel、Node.js、Docker、静的ホスティング等

Cloudflare(組み込み機能利用時)

Cloudflare(Void経由のデプロイ時)

なお、Next.jsもVercel専用というわけではなく、DockerコンテナやNode.jsサーバー、静的エクスポートなど多様な実行環境を公式にサポートしています。単純に「Next.js=Vercel、Void=Cloudflare」と対比させるのではなく、各フレームワークがどのようなアーキテクチャを採用しているかを見極めることが大切です。Next.jsデプロイガイド

まとめ:Cloudflareエコシステムを最速で束ねるオーケストレーションツール

今回実際に認証付きメモアプリを作ってみて最も魅力的だと感じたのは、「アプリ開発に必要なピースを個別に配線する手間が圧倒的に削減される」という点でした。データベースの接続設定、Better Authによる認証エンドポイントの構成、APIとUIを繋ぐ型定義、そしてフォームのステート管理が、Voidのシンプルな規約に従うだけで綺麗に繋がります。サーバー側の loader から取得したデータをそのままUIコンポーネントにPropsとして渡す設計も、見通しが良く扱いやすいと感じました。

この快適な開発体験は、Cloudflareのインフラ基盤と密接に結びつくことによって実現されています。ローカル開発から本番まで手軽に扱えるD1データベース、外部DBへ安全かつ高速に接続するHyperdrive、オブジェクトストレージのR2、そしてWorkers AIやAI GatewayといったCloudflareの強力なプロダクト群が、標準のビルディングブロックとして綺麗に整えられています。

なお、Voidの公開リポジトリはMITライセンスのオープンソースであり、SDKだけでなくチーム向け基盤であるVoid Platformのソースコードも公開されています。「Cloudflareのサービスに深く依存している」ということと「プロプライエタリなブラックボックスである」ということは明確に区別して捉える必要があります。GitHub: voidzero-dev/void

フルスタックな開発体験を提供するという意味では「Next.jsの対抗馬」という見方もできますが、実際に触ってみて強く実感したのは、Voidの本質が 「Cloudflare上でWebアプリを開発し、本番環境へ届けるまでの『選択と接続』を最小化するオーケストレーションツール」 であるということです。チーム向けのVoid Platformまで視野に入れると、単なるReactルーターの代替にとどまらない広範な開発プラットフォームを目指していることが伺えます。

すでにCloudflare WorkersやD1、R2を中心としたインフラ構成を前提としているプロジェクトであれば、Voidの採用は大いに検討する価値があります。一方で、将来的にAWSや自社サーバー(Node.js環境)へ移行する可能性がある場合は、void/db や void/auth といった組み込み機能への依存度をあらかじめ考慮し、抽象化のレイヤーを意識しておくことをお勧めします。小さなメモアプリの開発を通じて、そのメリットと依存関係の境界線を具体的に掴むことができました。