取り組みの背景:なぜ Convex × Antigravity なのか
個人開発者や小規模チームにとって、リアルタイム協調機能を持つ SaaS を素早く立ち上げることは長年の課題でした。ただ、その難しさは「リアルタイムに同期させること」そのものではありません。同期の実装だけなら数日で形になります。
個人開発でドキュメント共有機能を作ったときは、編集が他のタブへ流れるところまで 2 日で動きました。残りの 5 日は「タブを閉じて開き直したら片方の編集が消える」を潰すのに費やしています。再接続時の取りこぼし、オフライン中の書き込みのマージ、購読の解除漏れ。時間を奪うのは常にこちらでした。
Convex はこの配分を変えてくれるリアクティブバックエンドです。型安全なスキーマ定義、購読の自動管理、サーバーレス関数の実行が一体になっていて、クライアント側に WebSocket のコードを 1 行も書きません。Antigravity IDE と組み合わせると、スキーマを変えたときに呼び出し側の型エラーを一括で追いかける往復が目に見えて短くなります。
以下では、リアルタイム協調ドキュメント編集 SaaS を Convex と Antigravity で組み立てます。Clerk による認証、Convex 内蔵ストレージへのファイルアップロード、Stripe のサブスクリプション課金、Cloudflare Pages へのデプロイまで、実際に課金を受け取れる状態を到達点に置いています。
途中で一度、遠回りをします。Stripe Webhook を Next.js の Route Handler で受ける、という多くの記事が載せている実装が、Convex では動きません。型エラーとしては出ず、Stripe ダッシュボードの Webhook ログが 500 で埋まって初めて気づく種類の失敗です。私が本番で踏んだ箇所なので、素直な実装を一度置いてから、3 つの原因を順に潰していきます。
1. プロジェクトのセットアップ
1-1. 必要なツールと環境
# Node.js 20+ が必要
node --version # v20.x.x 以上
# pnpm を推奨
npm install -g pnpm
# Convex CLI
pnpm add -g convex
まず Antigravity IDE を開き、新しいプロジェクトを作成します。Antigravity の Agent に以下のように依頼します。
Next.js 14 + Convex + Clerk + Stripe + Tailwind CSS のプロジェクトを作成してください。
アプリ名は "CollabDocs" で、TypeScript strict モードで設定してください。
Agent が自動的に以下のコマンドを実行してくれます。
pnpm create next-app@latest collabdocs \
--typescript \
--tailwind \
--eslint \
--app \
--src-dir \
--import-alias "@/*"
cd collabdocs
# 依存関係のインストール
pnpm add convex @clerk/nextjs stripe @stripe/stripe-js
pnpm add -D @types/stripe
1-2. Convex プロジェクトの初期化
cd collabdocs
npx convex dev
初回実行時にブラウザが開き、Convex ダッシュボードへのログインを求められます。ログイン後、プロジェクトが自動作成されます。
.env.local に以下の環境変数が自動追加されます。
NEXT_PUBLIC_CONVEX_URL = https://your-project.convex.cloud
1-3. Antigravity でスキーマ設計を AI に相談する
スキーマ設計は SaaS の根幹です。Antigravity の Agent に以下のように相談します。
協調ドキュメント編集 SaaS のための Convex スキーマを設計してください。
以下のエンティティが必要です:
- ユーザー(Clerk から同期)
- ワークスペース(複数ユーザーが参加可能)
- ドキュメント(ワークスペースに属する)
- ドキュメント操作履歴
- サブスクリプション情報
Antigravity が生成するスキーマを確認し、必要に応じて修正します。
2. Convex スキーマ設計
2-1. テーブル定義
convex/schema.ts を以下のように定義します。
import { defineSchema, defineTable } from "convex/server" ;
import { v } from "convex/values" ;
export default defineSchema ({
// ユーザーテーブル(Clerk から同期)
users: defineTable ({
clerkId: v. string (),
email: v. string (),
name: v. string (),
imageUrl: v. optional (v. string ()),
plan: v. union (v. literal ( "free" ), v. literal ( "pro" )),
stripeCustomerId: v. optional (v. string ()),
stripeSubscriptionId: v. optional (v. string ()),
createdAt: v. number (),
})
. index ( "by_clerk_id" , [ "clerkId" ])
. index ( "by_email" , [ "email" ]) ,
// ワークスペーステーブル
workspaces: defineTable ({
name: v. string (),
ownerId: v. id ( "users" ),
plan: v. union (v. literal ( "free" ), v. literal ( "pro" )),
memberIds: v. array (v. id ( "users" )),
createdAt: v. number (),
updatedAt: v. number (),
})
. index ( "by_owner" , [ "ownerId" ]) ,
// ドキュメントテーブル
documents: defineTable ({
title: v. string (),
content: v. string (), // JSON 文字列(エディタ内部フォーマット)
workspaceId: v. id ( "workspaces" ),
authorId: v. id ( "users" ),
collaboratorIds: v. array (v. id ( "users" )),
storageId: v. optional (v. id ( "_storage" )), // 添付ファイル
isPublic: v. boolean (),
createdAt: v. number (),
updatedAt: v. number (),
})
. index ( "by_workspace" , [ "workspaceId" ])
. index ( "by_author" , [ "authorId" ]) ,
// 操作履歴テーブル(OT / CRDT ベースの差分記録)
documentOperations: defineTable ({
documentId: v. id ( "documents" ),
userId: v. id ( "users" ),
operation: v. string (), // JSON 文字列(差分データ)
version: v. number (),
timestamp: v. number (),
})
. index ( "by_document" , [ "documentId" ])
. index ( "by_document_version" , [ "documentId" , "version" ]) ,
}) ;
2-2. スキーマ設計のポイント
Antigravity の Agent に「このスキーマの問題点と改善案を教えてください」と依頼すると、以下のようなフィードバックが返ってきます。
content フィールドを文字列ではなく Convex の v.any() または専用の型で管理すると型安全性が向上する
memberIds 配列は大規模ワークスペースでパフォーマンス問題になる可能性がある(中間テーブルを検討)
documentOperations は時系列クエリが多いため timestamp の複合インデックスを追加すべき
このような AI との対話によるスキーマ改善が、Antigravity 開発の醍醐味です。
3. Convex クエリ・ミューテーション・アクションの実装
3-1. ドキュメント CRUD
convex/documents.ts を作成します。
import { query, mutation } from "./_generated/server" ;
import { v } from "convex/values" ;
import { getCurrentUser } from "./users" ;
// ドキュメント一覧取得(リアルタイム同期)
export const listByWorkspace = query ({
args: { workspaceId: v. id ( "workspaces" ) },
handler : async ( ctx , args ) => {
const user = await getCurrentUser (ctx);
if ( ! user) return [];
const docs = await ctx.db
. query ( "documents" )
. withIndex ( "by_workspace" , ( q ) =>
q. eq ( "workspaceId" , args.workspaceId)
)
. order ( "desc" )
. collect ();
// アクセス権チェック
return docs. filter (
( doc ) =>
doc.isPublic ||
doc.authorId === user._id ||
doc.collaboratorIds. includes (user._id)
);
},
});
// ドキュメント作成
export const create = mutation ({
args: {
title: v. string (),
workspaceId: v. id ( "workspaces" ),
},
handler : async ( ctx , args ) => {
const user = await getCurrentUser (ctx);
if ( ! user) throw new Error ( "認証が必要です" );
const now = Date. now ();
const docId = await ctx.db. insert ( "documents" , {
title: args.title,
content: JSON . stringify ({ type: "doc" , content: [] }),
workspaceId: args.workspaceId,
authorId: user._id,
collaboratorIds: [],
isPublic: false ,
createdAt: now,
updatedAt: now,
});
return docId;
},
});
// コンテンツ更新(リアルタイム同期のコア)
export const updateContent = mutation ({
args: {
documentId: v. id ( "documents" ),
content: v. string (),
operation: v. string (), // 差分データ
},
handler : async ( ctx , args ) => {
const user = await getCurrentUser (ctx);
if ( ! user) throw new Error ( "認証が必要です" );
const doc = await ctx.db. get (args.documentId);
if ( ! doc) throw new Error ( "ドキュメントが見つかりません" );
// 権限チェック
const canEdit =
doc.authorId === user._id ||
doc.collaboratorIds. includes (user._id);
if ( ! canEdit) throw new Error ( "編集権限がありません" );
const now = Date. now ();
// ドキュメント更新
await ctx.db. patch (args.documentId, {
content: args.content,
updatedAt: now,
});
// 操作履歴を記録
const lastOp = await ctx.db
. query ( "documentOperations" )
. withIndex ( "by_document" , ( q ) =>
q. eq ( "documentId" , args.documentId)
)
. order ( "desc" )
. first ();
await ctx.db. insert ( "documentOperations" , {
documentId: args.documentId,
userId: user._id,
operation: args.operation,
version: (lastOp?.version ?? 0 ) + 1 ,
timestamp: now,
});
},
});
3-2. リアルタイム同期の仕組み
Convex の最大の強みは、クエリが自動的にリアルタイム同期される点です。React コンポーネントで useQuery を使うだけで、データが変更されると UI が自動更新されます。
// app/workspace/[id]/page.tsx
"use client" ;
import { useQuery } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
import { Id } from "@/convex/_generated/dataModel" ;
export default function WorkspacePage ({
params ,
} : {
params : { id : string };
}) {
// このクエリはサーバー側でデータが変わると自動的に再実行される
const documents = useQuery (api.documents.listByWorkspace, {
workspaceId: params.id as Id < "workspaces" >,
});
if (documents === undefined ) {
return < div className = "animate-pulse" >読み込み中 ...</ div > ;
}
return (
< div className = "grid gap-4 p-6" >
{ documents . map (( doc ) => (
< DocumentCard key = {doc._id} document = {doc} />
))}
</ div >
);
}
ユーザー A が updateContent ミューテーションを実行すると、同じワークスペースを表示しているユーザー B の画面も自動的に更新されます。WebSocket の管理や再接続処理を自分で書く必要は一切ありません。
4. Clerk 認証の統合
4-1. Clerk + Convex の連携設定
// convex/auth.config.ts
export default {
providers: [
{
domain: process.env. CLERK_JWT_ISSUER_DOMAIN ! ,
applicationID: "convex" ,
},
] ,
} ;
// convex/users.ts
import { internalMutation, query, QueryCtx } from "./_generated/server" ;
import { v } from "convex/values" ;
// 現在のユーザーを取得するヘルパー
export const getCurrentUser = async ( ctx : QueryCtx ) => {
const identity = await ctx.auth. getUserIdentity ();
if ( ! identity) return null ;
return await ctx.db
. query ( "users" )
. withIndex ( "by_clerk_id" , ( q ) =>
q. eq ( "clerkId" , identity.subject)
)
. unique ();
};
// Clerk Webhook からユーザーを同期
export const syncUser = internalMutation ({
args: {
clerkId: v. string (),
email: v. string (),
name: v. string (),
imageUrl: v. optional (v. string ()),
},
handler : async ( ctx , args ) => {
const existing = await ctx.db
. query ( "users" )
. withIndex ( "by_clerk_id" , ( q ) => q. eq ( "clerkId" , args.clerkId))
. unique ();
if (existing) {
await ctx.db. patch (existing._id, {
email: args.email,
name: args.name,
imageUrl: args.imageUrl,
});
return existing._id;
}
return await ctx.db. insert ( "users" , {
... args,
plan: "free" ,
createdAt: Date. now (),
});
},
});
4-2. Webhook エンドポイントの実装
app/api/clerk/webhook/route.ts を作成します。
import { Webhook } from "svix" ;
import { headers } from "next/headers" ;
import { WebhookEvent } from "@clerk/nextjs/server" ;
import { ConvexHttpClient } from "convex/browser" ;
import { api } from "@/convex/_generated/api" ;
const convex = new ConvexHttpClient (process.env. NEXT_PUBLIC_CONVEX_URL ! );
export async function POST ( req : Request ) {
const WEBHOOK_SECRET = process.env. CLERK_WEBHOOK_SECRET ;
if ( ! WEBHOOK_SECRET ) {
throw new Error ( "CLERK_WEBHOOK_SECRET が設定されていません" );
}
const headerPayload = headers ();
const svix_id = headerPayload. get ( "svix-id" );
const svix_timestamp = headerPayload. get ( "svix-timestamp" );
const svix_signature = headerPayload. get ( "svix-signature" );
if ( ! svix_id || ! svix_timestamp || ! svix_signature) {
return new Response ( "Bad Request" , { status: 400 });
}
const payload = await req. json ();
const body = JSON . stringify (payload);
const wh = new Webhook ( WEBHOOK_SECRET );
let evt : WebhookEvent ;
try {
evt = wh. verify (body, {
"svix-id" : svix_id,
"svix-timestamp" : svix_timestamp,
"svix-signature" : svix_signature,
}) as WebhookEvent ;
} catch (err) {
return new Response ( "Signature verification failed" , { status: 400 });
}
if (evt.type === "user.created" || evt.type === "user.updated" ) {
const { id , email_addresses , first_name , last_name , image_url } =
evt.data;
await convex. mutation (api.users.syncUser, {
clerkId: id,
email: email_addresses[ 0 ]?.email_address ?? "" ,
name: `${ first_name ?? ""} ${ last_name ?? ""}` . trim (),
imageUrl: image_url,
});
}
return new Response ( "OK" , { status: 200 });
}
5. ファイルストレージの実装
Convex はファイルストレージを内蔵しており、別途 S3 や Cloudflare R2 を用意する必要がありません。
5-1. ファイルアップロードフロー
// convex/files.ts
import { mutation, query } from "./_generated/server" ;
import { v } from "convex/values" ;
import { getCurrentUser } from "./users" ;
// アップロード URL の生成
export const generateUploadUrl = mutation ({
handler : async ( ctx ) => {
const user = await getCurrentUser (ctx);
if ( ! user) throw new Error ( "認証が必要です" );
return await ctx.storage. generateUploadUrl ();
},
});
// ファイルをドキュメントに紐付け
export const attachToDocument = mutation ({
args: {
documentId: v. id ( "documents" ),
storageId: v. id ( "_storage" ),
},
handler : async ( ctx , args ) => {
const user = await getCurrentUser (ctx);
if ( ! user) throw new Error ( "認証が必要です" );
await ctx.db. patch (args.documentId, {
storageId: args.storageId,
updatedAt: Date. now (),
});
},
});
// ファイル URL の取得
export const getFileUrl = query ({
args: { storageId: v. id ( "_storage" ) },
handler : async ( ctx , args ) => {
return await ctx.storage. getUrl (args.storageId);
},
});
5-2. React コンポーネントでのアップロード実装
// components/FileUpload.tsx
"use client" ;
import { useMutation, useQuery } from "convex/react" ;
import { api } from "@/convex/_generated/api" ;
import { Id } from "@/convex/_generated/dataModel" ;
import { useCallback, useState } from "react" ;
export function FileUpload ({
documentId ,
} : {
documentId : Id < "documents" >;
}) {
const [ uploading , setUploading ] = useState ( false );
const generateUploadUrl = useMutation (api.files.generateUploadUrl);
const attachToDocument = useMutation (api.files.attachToDocument);
const handleUpload = useCallback (
async ( e : React . ChangeEvent < HTMLInputElement >) => {
const file = e.target.files?.[ 0 ];
if ( ! file) return ;
setUploading ( true );
try {
// 1. アップロード URL を取得
const uploadUrl = await generateUploadUrl ();
// 2. ファイルをアップロード
const result = await fetch (uploadUrl, {
method: "POST" ,
headers: { "Content-Type" : file.type },
body: file,
});
const { storageId } = await result. json ();
// 3. ドキュメントに紐付け
await attachToDocument ({ documentId, storageId });
} finally {
setUploading ( false );
}
},
[generateUploadUrl, attachToDocument, documentId]
);
return (
< label className = "cursor-pointer inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700" >
{ uploading ? "アップロード中..." : "ファイルを添付" }
< input
type = "file"
className = "hidden"
onChange = {handleUpload}
disabled = {uploading}
/>
</ label >
);
}
6. Stripe 課金の統合
6-1. まず「動かない実装」を置きます
Convex と Stripe を組み合わせる記事でよく見かけるのが、次のように Next.js の Route Handler で Webhook を受け、ConvexHttpClient で Convex 側の Mutation を呼ぶ形です。私も最初はこれを書きました。
このコードは 3 か所で落ちます。うち 2 か所は TypeScript が教えてくれず、1 か所は本番でのみ表面化します。次の H2 で 1 つずつ直しますので、まずは素直な形のまま読んでください。
// app/api/stripe/webhook/route.ts(このままでは動きません)
import Stripe from "stripe" ;
import { headers } from "next/headers" ;
import { ConvexHttpClient } from "convex/browser" ;
import { api } from "@/convex/_generated/api" ;
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
const convex = new ConvexHttpClient (process.env. NEXT_PUBLIC_CONVEX_URL ! );
export async function POST ( req : Request ) {
const body = await req. text ();
const sig = headers (). get ( "stripe-signature" ) ! ;
let event : Stripe . Event ;
try {
event = stripe.webhooks. constructEvent (
body,
sig,
process.env. STRIPE_WEBHOOK_SECRET !
);
} catch (err) {
return new Response ( "Signature verification failed" , { status: 400 });
}
switch (event.type) {
case "checkout.session.completed" : {
const session = event.data.object as Stripe . CheckoutSession ;
const clerkId = session.metadata?.clerkId;
if ( ! clerkId) break ;
// ユーザーをアップグレード
await convex. mutation (api.subscriptions.activate, {
clerkId,
stripeCustomerId: session.customer as string ,
stripeSubscriptionId: session.subscription as string ,
});
break ;
}
case "customer.subscription.deleted" : {
const subscription = event.data.object as Stripe . Subscription ;
await convex. mutation (api.subscriptions.deactivate, {
stripeSubscriptionId: subscription.id,
});
break ;
}
}
return new Response ( "OK" , { status: 200 });
}
6-2. サブスクリプション管理 Mutation
// convex/subscriptions.ts
import { internalMutation } from "./_generated/server" ;
import { v } from "convex/values" ;
export const activate = internalMutation ({
args: {
clerkId: v. string (),
stripeCustomerId: v. string (),
stripeSubscriptionId: v. string (),
},
handler : async ( ctx , args ) => {
const user = await ctx.db
. query ( "users" )
. withIndex ( "by_clerk_id" , ( q ) => q. eq ( "clerkId" , args.clerkId))
. unique ();
if ( ! user) throw new Error ( "ユーザーが見つかりません" );
await ctx.db. patch (user._id, {
plan: "pro" ,
stripeCustomerId: args.stripeCustomerId,
stripeSubscriptionId: args.stripeSubscriptionId,
});
},
});
export const deactivate = internalMutation ({
args: { stripeSubscriptionId: v. string () },
handler : async ( ctx , args ) => {
const user = await ctx.db
. query ( "users" )
. filter (( q ) =>
q. eq (q. field ( "stripeSubscriptionId" ), args.stripeSubscriptionId)
)
. unique ();
if ( ! user) return ;
await ctx.db. patch (user._id, {
plan: "free" ,
stripeSubscriptionId: undefined ,
});
},
});
Webhook が 500 を返し続けた 3 つの理由
前節のコードを本番に上げると、Stripe ダッシュボードの Webhook ログが 500 で埋まります。ローカルの stripe listen でも同じです。原因は 3 つあり、性質がそれぞれ違います。
理由 1: internalMutation はクライアントから呼べない
6-2 で activate / deactivate を internalMutation として定義しました。ところが 6-1 では、それを ConvexHttpClient から api.subscriptions.activate として呼んでいます。これは成立しません。
Convex の公式ドキュメントは「Internal functions can only be called by other functions and cannot be called directly from a Convex client」と明記しています。内部関数は _generated/api の api オブジェクトではなく internal オブジェクトに載り、呼べるのは他の Convex 関数(ctx.runMutation やスケジューラ経由)だけです。
ここで選択肢は 2 つに割れます。
選択肢 やること 向き不向き
A. 公開 mutation にする internalMutation を mutation に変え、引数に共有シークレットを足して自前で検証する手軽ですが、課金状態を書き換える関数を公開面に出すことになります。シークレットの検証を書き忘れた瞬間に、誰でも自分を Pro にできます
B. Convex の HTTP Action で受ける Webhook 自体を Convex 側で受け、署名検証の後に ctx.runMutation(internal.…) を呼ぶ 内部関数を内部のまま保てます。Next.js 側に課金の中継コードが残らないのも利点です
私は B を選びました。理由は単純で、A は「シークレット検証を書き忘れない」という運用上の約束に安全性を預けているからです。課金に関わる関数は、公開面に出さないで済むなら出さないほうが後々楽でした。
理由 2: Stripe.CheckoutSession という型は存在しない
event.data.object as Stripe.CheckoutSession は、stripe パッケージの型定義には見つかりません。正しくは名前空間がひとつ挟まった Stripe.Checkout.Session です。サブスクリプション側は Stripe.Subscription でよいので、片方だけ間違えていても気づきにくい箇所でした。
// 誤り
const session = event.data.object as Stripe . CheckoutSession ;
// 正しい
const session = event.data.object as Stripe . Checkout . Session ;
理由 3: Next.js 15 以降、headers() は Promise を返す
headers().get("stripe-signature") は Next.js 14 までの書き方です。Next.js 15 で headers / cookies / draftMode は非同期化され、Promise を返すようになりました。互換のため同期アクセスも当面は通りますが、開発時と本番の両方で警告が出ます。await を付けるだけで済みます。
const sig = ( await headers ()). get ( "stripe-signature" ) ! ;
移行だけであれば npx @next/codemod@canary next-async-request-api が該当箇所をまとめて書き換えてくれます。
直した形:convex/http.ts に寄せる
3 つをまとめて解消すると、Webhook の受け口は Convex 側へ移ります。Convex は convex/http.ts で httpRouter にルートを登録し、httpAction でハンドラを書く形式です。httpAction の中では ctx.runMutation / ctx.runQuery / ctx.runAction が使えるので、内部関数をそのまま呼べます。
// convex/http.ts
import { httpRouter } from "convex/server" ;
import { httpAction } from "./_generated/server" ;
import { internal } from "./_generated/api" ;
import Stripe from "stripe" ;
const http = httpRouter ();
http. route ({
path: "/stripe/webhook" ,
method: "POST" ,
handler: httpAction ( async ( ctx , request ) => {
const stripe = new Stripe (process.env. STRIPE_SECRET_KEY ! );
const body = await request. text ();
const sig = request.headers. get ( "stripe-signature" );
if ( ! sig) return new Response ( "Missing signature" , { status: 400 });
let event : Stripe . Event ;
try {
// Workers ランタイムでは同期版ではなく Async 版を使います
event = await stripe.webhooks. constructEventAsync (
body,
sig,
process.env. STRIPE_WEBHOOK_SECRET !
);
} catch {
return new Response ( "Signature verification failed" , { status: 400 });
}
switch (event.type) {
case "checkout.session.completed" : {
const session = event.data.object as Stripe . Checkout . Session ;
const clerkId = session.metadata?.clerkId;
if ( ! clerkId) break ;
await ctx. runMutation (internal.subscriptions.activate, {
clerkId,
stripeCustomerId: session.customer as string ,
stripeSubscriptionId: session.subscription as string ,
});
break ;
}
case "customer.subscription.deleted" : {
const subscription = event.data.object as Stripe . Subscription ;
await ctx. runMutation (internal.subscriptions.deactivate, {
stripeSubscriptionId: subscription.id,
});
break ;
}
}
// 200 を返さないと Stripe は再送を続けます
return new Response ( null , { status: 200 });
}),
});
export default http;
constructEventAsync にしている点だけ補足します。Convex の HTTP Action も Cloudflare Workers も Node.js の crypto を前提にした同期版の署名検証が使えない環境です。同期版のまま上げると、署名は正しいのに検証だけが失敗するという、いちばん時間を取られる形の失敗になります。
もうひとつ、忘れやすいのが Stripe ダッシュボードに登録するエンドポイント URL です。Convex の HTTP Action は .convex.cloud ではなく .convex.site で配信されます。
https://<your-deployment>.convex.site/stripe/webhook
convex.cloud のまま登録すると 404 が返り、Stripe 側は「エンドポイントが存在しない」としか教えてくれません。私はここで 1 時間ほど溶かしました。
6-2 の internalMutation はそのままで構いません。api ではなく internal から呼ぶようになった、というのがこの節の実質的な変更点です。Next.js 側の app/api/stripe/webhook/route.ts は削除できます。
7. Antigravity による高速デバッグとリファクタリング
7-1. よくあるエラーと Antigravity の対処
エラー 1: Convex クエリの引数型エラー
Type 'string' is not assignable to type 'Id<"workspaces">'
Antigravity の Agent に「この型エラーを修正してください」と貼り付けると、以下の解決策を提示してくれます。
// 修正前
const docs = useQuery (api.documents.listByWorkspace, {
workspaceId: params.id, // string 型
});
// 修正後
import { Id } from "@/convex/_generated/dataModel" ;
const docs = useQuery (api.documents.listByWorkspace, {
workspaceId: params.id as Id < "workspaces" >, // 正しい型
});
エラー 2: Convex 関数からの非同期処理
Convex のクエリ・ミューテーション内では外部 API を直接呼び出せません。この場合は action を使います。
// convex/ai.ts(Convex Action の例)
import { action } from "./_generated/server" ;
import { v } from "convex/values" ;
export const summarizeDocument = action ({
args: { content: v. string () },
handler : async ( ctx , args ) => {
// Action は外部 API 呼び出し可能
const response = await fetch ( "https://generativelanguage.googleapis.com/v1beta/..." , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ({
contents: [{ parts: [{ text: `以下を要約してください: \n\n ${ args . content }` }] }],
}),
});
const data = await response. json ();
return data.candidates[ 0 ].content.parts[ 0 ].text;
},
});
7-2. Antigravity でコードレビューを自動化
コードを書いた後、Antigravity に「このコードのセキュリティ問題と最適化の余地を教えてください」と依頼することで、以下のような指摘が得られます。
Convex ミューテーション内でのユーザー権限チェックの漏れ
collect() ではなく paginate() を使うべき大量データクエリ
インデックスが活用されていないクエリパターン
これにより、本番リリース前に問題を事前に発見できます。
8. Cloudflare Pages へのデプロイ
8-1. デプロイ設定
# @cloudflare/next-on-pages をインストール
pnpm add -D @cloudflare/next-on-pages
# wrangler.toml を作成
cat > wrangler.toml << 'EOF'
name = "collabdocs"
compatibility_date = "2024-01-01"
compatibility_flags = ["nodejs_compat"]
[build]
command = "pnpm run build"
EOF
package.json に以下を追加します。
{
"scripts" : {
"build" : "next build" ,
"pages:build" : "npx @cloudflare/next-on-pages" ,
"deploy" : "pnpm pages:build && wrangler pages deploy .vercel/output/static"
}
}
8-2. 環境変数の設定
Cloudflare Pages ダッシュボードで以下の環境変数を設定します。
NEXT_PUBLIC_CONVEX_URL: Convex のデプロイメント URL
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: Clerk の公開キー
CLERK_SECRET_KEY: Clerk の秘密キー
CLERK_WEBHOOK_SECRET: Clerk Webhook 署名検証キー
STRIPE_SECRET_KEY: Stripe 秘密キー
STRIPE_WEBHOOK_SECRET: Stripe Webhook 署名検証キー
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: Stripe 公開キー
8-3. Convex の本番デプロイ
# Convex 本番環境のデプロイ
npx convex deploy
# 本番環境の Convex URL を取得して Cloudflare に設定
9. プラン制限の実装
Free プランと Pro プランで機能を分けることで、アップグレードへの動機付けを作ります。
// convex/workspaces.ts(プラン制限付き)
export const create = mutation ({
args: { name: v. string () },
handler : async ( ctx , args ) => {
const user = await getCurrentUser (ctx);
if ( ! user) throw new Error ( "認証が必要です" );
// Free プランの制限チェック
if (user.plan === "free" ) {
const existingWorkspaces = await ctx.db
. query ( "workspaces" )
. withIndex ( "by_owner" , ( q ) => q. eq ( "ownerId" , user._id))
. collect ();
if (existingWorkspaces. length >= 3 ) {
throw new Error (
"Free プランではワークスペースを3つまでしか作成できません。Pro にアップグレードしてください。"
);
}
}
const now = Date. now ();
return await ctx.db. insert ( "workspaces" , {
name: args.name,
ownerId: user._id,
plan: user.plan,
memberIds: [user._id],
createdAt: now,
updatedAt: now,
});
},
});
個人開発者の視点から(実体験メモ)
ここまで手順として書いてきましたが、実際に一人で回してみて感じたことを、そのまま残しておきます。
Antigravity が最も効いたのは、コードを書く場面ではありませんでした。 スキーマを 1 か所変えたときに、呼び出し側で壊れる箇所を全部つないで直す往復です。documents テーブルに archivedAt を足したときは、クエリ・ミューテーション・React 側のコンポーネントで計 11 か所が型エラーになりました。以前なら順に潰していた作業ですが、Agent に「このスキーマ変更に追随してください」と渡すと、9 か所は一度で通りました。残り 2 か所は、そもそも設計として archived を含めるべきか迷いのあった箇所です。判断が要る場所だけが手元に残る、という分かれ方でした。
Convex を選ばなかったほうがよかった場面もあります。 集計用に 30 万件近いイベントログを入れようとしたときです。Convex はドキュメント単位の購読が得意な分、大量の行を集計で舐める用途とは相性が良くありません。collect() が実行時間の上限に触れ、結局この部分だけは外部の分析基盤に逃がしました。「協調編集の状態」と「積み上がる一方のログ」を同じ場所に置こうとしたのが誤りだったと思っています。
課金まわりは、実装より観測に時間を使うべきでした。 Webhook が動かなかった 3 つの理由は、どれも直すこと自体は 5 分です。時間を奪ったのは「どれが原因か分からない」状態のほうでした。convex logs を開いたまま stripe trigger checkout.session.completed を叩く、という手順を最初にやっていれば、おそらく半日は短縮できています。ローカルで再現できる形を先に作る、というだけの話なのですが、実装が動き始めると後回しにしがちでした。
そして、Free プランの制限は最初から入れておくほうが楽です。 9 節の existingWorkspaces.length >= 3 のようなチェックは、後から足そうとすると「すでに 4 つ作ってしまっている既存ユーザー」をどう扱うかという移行の問題に変わります。作る側としては最後に回したくなる部分ですが、ここだけは最初のスキーマと一緒に置いてしまうのを勧めます。
まとめ
Convex と Antigravity の組み合わせで実際に短くなったのは、リアルタイム同期そのものの実装というより、設計を変えたときに払うコスト でした。スキーマを一度置いたら動かしにくい、という感覚が薄れる分、最初の設計に完璧さを求めなくてよくなります。
一方で、課金の接続だけは AI 支援の外側にありました。internalMutation を api から呼ぼうとしていた誤りも、.convex.site と .convex.cloud の取り違えも、コードとしては正しく見えてしまうからです。ここは公式ドキュメントを読む時間をそのまま確保するしかない、というのが今のところの結論です。
次に手を動かすなら、まず convex/http.ts を 1 本作って、stripe trigger checkout.session.completed がローカルで通るところまでを先に用意してみてください。アプリ本体を作り込む前にこの一本の線が通っていると、その後の作業の見通しがまったく変わります。
Convex はまだ手探りで使っている部分が多く、私自身このスタックの正解を持っているわけではありません。ここに書いたのは、個人開発で一度転んだ場所の記録です。同じところで止まっている方の遠回りが少しでも短くなれば嬉しく思います。