取り組みの背景:なぜ Convex × Antigravity なのか
個人開発者や小規模チームにとって、リアルタイム協調機能を持つ SaaS を素早く立ち上げることは長年の課題でしました。WebSocket の複雑な実装、データ同期の整合性、スケーラビリティの確保——これらを一人でこなすのは容易ではありませんでしました。
Convex は、この問題を根本から解決するリアクティブバックエンドプラットフォームです。型安全なスキーマ定義、自動的なリアルタイム同期、サーバーレス関数のシームレスな実行を提供します。そして Antigravity IDE と組み合わせることで、AI がコード補完・エラー修正・アーキテクチャ提案を行い、開発速度が劇的に向上します。
ここで扱うのはConvex と Antigravity を使ってリアルタイム協調ドキュメント編集 SaaS を構築する完全な手順を解説します。認証(Clerk)、ファイルストレージ、Stripe 課金まで、実際に収益化できるプロダクトレベルの実装を目指します。
対象読者
- Next.js や React の基礎知識がある方
- バックエンド開発経験はあるが Convex は初めての方
- Antigravity IDE を使って SaaS を素早く立ち上げたい方
- リアルタイム機能の実装に苦労した経験がある方
完成するもの
この記事を読み終えると、以下の機能を持つ SaaS アプリケーションが完成します。
- リアルタイム協調ドキュメント編集(複数ユーザーが同時編集可能)
- Clerk による認証(Google / GitHub / メールアドレス対応)
- ファイルアップロードと CDN 配信
- Stripe によるサブスクリプション課金(Free / Pro プラン)
- Cloudflare Pages へのデプロイ
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. Stripe Webhook エンドポイント
// 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,
});
},
});
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 と Convex を組み合わせることで、かつては数週間かかっていたリアルタイム協調 SaaS の構築が、数日で完成するようになりましました。本記事で解説した主要な実装ポイントをまとめます。
- Convex スキーマ設計: インデックスを意識した型安全なテーブル定義
- リアルタイム同期:
useQuery だけで WebSocket 管理が不要になる
- Clerk 認証: Webhook で Convex のユーザーテーブルと自動同期
- ファイルストレージ: Convex 内蔵ストレージで S3 不要
- Stripe 課金: Webhook → Convex Mutation でプラン管理
- プラン制限: Mutation 内でのチェックにより安全な機能制限
Convex + Antigravity の組み合わせをさらに深く学びたい方には、Convex と Clerk を使った認証済みリアルタイムアプリの実装パターンを解説した書籍もあわせて参考にしてください。
関連記事として、Antigravity × Supabase リアルタイム完全ガイドやAntigravity × Stripe アプリ収益化ガイドもぜひご覧ください。