Next.js × Convex × Clerkで認証付きフルスタックアプリをラクに開発する
はじめに
この記事では、Next.js × Convex × Clerk でフルスタックアプリを作る方法をハンズオン形式で紹介します。
Convex と Clerk を組み合わせることで、認証付きのフルスタックアプリを爆速で開発することができます。
完成物は、以下の GitHub リポジトリで公開しています。
Convex とは
Convex は、Firebase のようなバックエンドアプリケーションプラットフォームです。リアルタイムデータベースやファイルストレージなどの機能を提供しています。
Firebase との違いは、以下の記事で詳しく解説されています。
Clerk とは
Clerk は、認証・認可を提供するサービスです。Clerk と Convex を併用することで、認証・認可の実装を非常に簡単に行うことができます。
Next.js での認証といえば、NextAuth が思い浮かびます。NextAuth では middleware を記述したり、認証のための API を実装する必要があります。また、ログインページもゼロから作らなければなりません。これらは認証について理解がなければつまづいてしまいますし、実装にも時間がかかります。
しかし、Clerk では必要ありません。Clerk は認証にまつわる Hooks や UI を提供してくれるため、認証について理解がなくても簡単に認証機能を実装することができます。
技術スタック
- Next.js v14.1.0 (App router)
- Convex
- Clerk
- (Tailwind CSS)
セットアップ
Next.js のセットアップ
まず初めに Next.js のプロジェクトを作成します。途中の選択肢は全てデフォルトのままで大丈夫です。
$ npx create-next-app@14.1.0 nextjs-convex-clerk-sample
作成したプロジェクトに移動します。
$ cd nextjs-convex-clerk-sample
開発サーバを起動します。
$ npm run dev
Convex のセットアップ
まず、convex.dev にアクセスして、GitHub でログインしておきます。
続いて、Convex をセットアップします。新しいターミナルを開いて、以下のコマンドを実行します。( npm run dev
は終了させないでください)
$ npm install convex
以下のコマンドを実行するとプロジェクトの作成・選択と開発用のバックエンドサーバが起動します。
GitHub でログインしていない場合は、ログインを求められるので、ログインしてください。
$ npx convex dev
選択肢が表示されます。ここでは a new project
を選択します。新しくプロジェクトが作成されるので、その名前を入力します。
$ npx convex dev
? What would you like to configure? a new project
? Project name: (nextjs-convex-clerk-sample)
作成が完了すると、 .env.local
が作成されます。このファイルには、Convex の開発用のバックエンドサーバの URL が記載されています。
# Deployment used by `npx convex dev`
CONVEX_DEPLOYMENT=dev:xxx # team: your-name, project: nextjs-convex-clerk-sample
NEXT_PUBLIC_CONVEX_URL=https://xxx.convex.cloud
Clerk のセットアップ
続いて Clerk をセットアップします。
まず、clerk.com にアクセスして、ログインしておきます。
ログインすると、ダッシュボード画面に遷移します。ここで、Add application
をクリックします。
モーダルが表示されるので、 Application name
に任意の名前を(ここでは nextjs-convex-clerk-sample
)、Google ログインのみチェックを入れて、右下の Create application
ボタンをクリックします。
作成が完了すると環境変数が表示されるので、それらをコピーして .env.local
に貼り付けます。
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx
CLERK_SECRET_KEY=sk_test_xxx
ターミナルに戻り、Clerk をインストールします。
$ npm install @clerk/nextjs
Convex と Clerk を連携する
最後に、Convex と Clerk を連携します。
こちらの公式の記事も参考になります。
JWT Template の作成
再度メニューから「JWT Template」を選択して以下のページ遷移します。
New Template
をクリックし、表示される項目から Convex
を選択します。
JWT Template の設定画面が表示されます。設定項目は特に変更する必要はありません。
ただし、「Issuer」の値はこの後の設定で使用するので、コピーしておいてください。
コピーしたら、右下の Apply Changes
をクリックします。
convex/auth.config.js
を作成する
convex/auth.config.js
を作成します。このファイルには、JWT Template の設定を記述します。
https://your-issuer-url.clerk.accounts.dev/
には、先ほどコピーした Issuer の値を入力してください。
export default {
providers: [
{
domain: "https://your-issuer-url.clerk.accounts.dev/",
applicationID: "convex",
},
]
};
最後に再度 npx convex dev
を実行します。
$ npx convex dev
convex-client-provider を作成する
app
ディレクトリと同階層にproviders
ディレクトリを作成します。続いて、providers
ディレクトリにconvex-client-provider.tsx
を作成します。
"use client";
import { ReactNode } from "react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
return (
<ClerkProvider
publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}
>
<ConvexProviderWithClerk useAuth={useAuth} client={convex}>
{children}
</ConvexProviderWithClerk>
</ClerkProvider>
);
}
app/layout.tsx
に移動し、ConvexClientProvider
を追加します。
import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
+ import ConvexClientProvider from "./ConvexClientProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
+ <ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
ここまでで、Next.js と Convex、 Clerk のセットアップは完了です。
ソーシャルログイン機能を実装する
まずはじめに、app/page.tsx
を開きます。
一旦全て削除し、以下のように書き換えます。(冒頭の "use client";
を忘れないでください!)
"use client";
export default function Home() {
return (
<div className="h-screen flex items-center justify-center flex-col gap-y-4">
<h1 className="text-xl font-semibold">ようこそ!</h1>
<div className="flex gap-4"></div>
</div>
);
}
併せて、app/globals.css
もリセットします。tailwind は使うので、そのまま残しておきます。
@tailwind base;
@tailwind components;
@tailwind utilities;
ログインボタンを設置する
本題のソーシャルログイン機能を実装します。といっても一瞬です。 <SignInButton>
を追加するだけです!👀
"use client";
+ import { SignInButton } from "@clerk/nextjs";
export default function Home() {
return (
<div className="h-screen flex items-center justify-center flex-col gap-y-4">
<h1 className="text-xl font-semibold">ようこそ!</h1>
<div className="flex gap-4">
+ <SignInButton mode="modal">
+ <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
+ ログイン
+ </button>
+ </SignInButton>
</div>
</div>
);
}
http://localhost:3000 にアクセスしてください。
表示されている「ログイン」ボタンをクリックすると、ログイン用の UI が表示されます。これは、 Clerk があらかじめ用意してくれているものです!
Continue with Google
をクリックすると、Google のログイン画面が表示されます。ここで Google アカウントでログインすると、ログインが完了します。
Clerk が用意してくれているコンポーネントは他にもたくさんあります。このように、Clerk とそれに対応している Convex を使うと最小限のコードでスマートな認証機能を実装することができます。
ログアウトする
ログアウトボタンの設置も簡単です。次のように <SignOutButton>
を追加します。
"use client";
+ import { SignInButton, SignOutButton } from "@clerk/nextjs";
export default function Home() {
return (
<div className="h-screen flex items-center justify-center flex-col gap-y-4">
<h1 className="text-xl font-semibold">ようこそ!</h1>
<div className="flex gap-4">
<SignInButton mode="modal">
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
ログイン
</button>
</SignInButton>
+ <SignOutButton>
+ <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
+ ログアウト
+ </button>
+ </SignOutButton>
</div>
</div>
);
}
以下のように表示されれば成功です!🎉
ユーザ情報を取得する
このままだと、ログインしているかどうかを UI に反映することができません。そこで、ユーザ情報を取得してみましょう。
app/page.tsx
を以下のように編集します。
"use client";
+ import { SignInButton, SignOutButton, useUser } from "@clerk/nextjs";
+ import { useConvexAuth } from "convex/react";
export default function Home() {
+ const { isAuthenticated, isLoading } = useConvexAuth();
+ const { user } = useUser();
+ if (isLoading) return <div>Loading...</div>;
return (
<div className="h-screen flex items-center justify-center flex-col gap-y-4">
<h1 className="text-xl font-semibold">
+ ようこそ!{isAuthenticated ? user?.fullName : "ゲスト"}さん
</h1>
<div className="flex gap-4">
+ {!isAuthenticated ? (
<SignInButton mode="modal">
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
ログイン
</button>
</SignInButton>
+ ) : (
<SignOutButton>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
ログアウト
</button>
</SignOutButton>
+ )}
</div>
</div>
);
}
再度 http://localhost:3000 にアクセスしログインすると、以下のようにアカウントの名前が表示されるようになります。
コードについて、2点解説します。
useConvexAuth
1. useConvexAuth
は、Convex の認証ステートを取得するフックです。
-
isAuthenticated
:ユーザが認証済みかどうかを表す真偽値です。 -
isLoading
:認証ステートのフェッチが完了しているかどうかを表す真偽値です。
useUser
2. useUser
は、Clerk の現在ログインしているユーザ情報を取得するフックです。user
の他にisSignedIn
やisLoaded
を返します。
データベースを使う(Convex)
schema を定義する
convex/schema.ts
を作成します。ここでは、messages
というテーブルを作成します。
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
title: v.string(),
content: v.optional(v.string()),
userId: v.string(),
}).index("by_user", ["userId"]),
});
定義すると、npx convex dev
を実行しているターミナルに以下のようなログが表示されます。
✔ Schema validation complete.
これによって、自動でデータベースが作成されます。
dashboard.convex.devにアクセスして、nextjs-convex-clerk-sample
を選択しましょう。messages
が表示されていることが確認できます。
schema の作成について、詳しくは公式ドキュメントを参照してください。
API を作成する
続いて、convex/messages.ts
を作成します。ここでは、messages
テーブルの CRUD API を作成します。
GET API を作成する
まず、GET API を作成します。getAll
という名前で作成します。
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getAll = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("UnAuthorized");
}
const userId = identity.subject;
const messages = await ctx.db
.query("messages")
.withIndex("by_user", (q) => q.eq("userId", userId))
.order("desc")
.collect();
return messages;
},
});
いくつか解説します。
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("UnAuthorized");
}
const userId = identity.subject;
ここでは認証を行なっています。ログインしていない場合は、UnAuthorized
というエラーを返します。また、identity.subject
は、ログインしているユーザの ID です。
const messages = await ctx.db
.query("messages")
.withIndex("by_user", (q) => q.eq("userId", userId))
.order("desc")
.collect();
ctx.db.query
でクエリを作成します。withIndex
でインデックスを指定します。インデックスを指定することで、クエリのパフォーマンスが向上します。
他にも様々なクエリが用意されています。詳しくは、公式ドキュメントを参照してください。
POST API を作成する
続いて、POST API を作成します。create
という名前で作成します。
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
...
export const create = mutation({
args: {
title: v.string(),
content: v.optional(v.string()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("UnAuthorized");
}
const userId = identity.subject;
const message = await ctx.db.insert("messages", {
title: args.title,
content: args.content,
userId,
});
return message;
},
});
GET API と同様に認証を行なったのちに、 ctx.db.insert
でデータを挿入しています。
args: {
title: v.string(),
content: v.optional(v.string()),
},
args
には、API に渡す引数を定義します。ここでは、title
とcontent
を定義しています。
v.id
はバリデーターと呼ばれるものです。条件に合致しない場合はエラーを返します。v.id("messages")
は、messages
テーブルの ID であることを表しています。
呼び出し側に対しても型情報が提供されるため、型安全にコーディングすることができます。
ここまでの `convex/messages.ts` のコードは、以下のようになります。
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
export const getAll = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("UnAuthorized");
}
const userId = identity.subject;
const messages = await ctx.db
.query("messages")
.withIndex("by_user", (q) => q.eq("userId", userId))
.order("desc")
.collect();
return messages;
},
});
export const create = mutation({
args: {
title: v.string(),
content: v.optional(v.string()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("UnAuthorized");
}
const userId = identity.subject;
const message = await ctx.db.insert("messages", {
title: args.title,
content: args.content,
userId,
});
return message;
},
});
API を呼び出す
最後に、先ほど作成した API を呼び出してみましょう。
今回は新しく /messages
というページを作成します。app/messages/layout.tsx
とapp/messages/page.tsx
を作成します。
"use client";
import { useConvexAuth } from "convex/react";
import { redirect } from "next/navigation";
const MessagesLayout = ({ children }: { children: React.ReactNode }) => {
const { isAuthenticated, isLoading } = useConvexAuth();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return redirect("/");
}
return <>{children}</>;
};
export default MessagesLayout;
"use client";
import { api } from "@/convex/_generated/api";
import { useMutation, useQuery } from "convex/react";
import { useState } from "react";
const Messages = () => {
const messages = useQuery(api.messages.getAll);
const create = useMutation(api.messages.create);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
await create({ title, content });
setTitle("");
setContent("");
};
return (
<div className="h-screen flex items-center justify-center flex-col gap-y-4">
<h1 className="text-2xl font-bold mb-4">メッセージ一覧</h1>
<ul className="list-none gap-y-4 flex flex-col">
{messages?.map((message, index) => (
<li
key={message._id}
className="flex gap-2 flex-col border border-gray-300 px-4 py-2 rounded-lg"
>
<div className="font-bold">{message.title}</div>
<div className="ml-4">{message.content}</div>
</li>
))}
</ul>
<form
onSubmit={handleSubmit}
className="w-full max-w-xl border-t border-gray-300 mt-8 pt-12"
>
<h2 className="text-xl font-bold text-center mb-8">
メッセージを作成する
</h2>
<div className="flex items-center justify-center flex-col -mx-3 mb-6 gap-4">
<div className="w-full md:w-1/2 px-3 mb-6 md:mb-0">
<label
className="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
htmlFor="title"
>
タイトル
</label>
<input
className="appearance-none block w-full bg-gray-200 text-gray-700 border rounded py-3 px-4 mb-3 leading-tight focus:outline-none focus:bg-white"
id="title"
type="text"
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="w-full md:w-1/2 px-3">
<label
className="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
htmlFor="content"
>
内容
</label>
<textarea
className="appearance-none block w-full bg-gray-200 text-gray-700 border border-gray-200 rounded py-3 px-4 leading-tight focus:outline-none focus:bg-white focus:border-gray-500"
id="content"
onChange={(e) => setContent(e.target.value)}
/>
</div>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
作成する
</button>
</div>
</form>
</div>
);
};
export default Messages;
コードを解説します。
const messages = useQuery(api.messages.getAll);
useQuery
とuseMutation
は、API を呼び出すためのフックです。useQuery
は、API を呼び出してその結果を取得します。
const create = useMutation(api.messages.create);
...
await create({ title, content });
useMutation
は、API を呼び出してその結果を取得します。create
は、api.messages.create
を呼び出すための関数です。create
に引数を渡すことで、API に引数を渡すことができます。
http://localhost:3000/messages にアクセスすると、以下のような画面が表示されます。
フォームを入力してメッセージを作成すると、以下のように表示されます 🎉
おわりに
今回は、Next.js × Convex × Clerk でフルスタックアプリをラクに開発する方法を紹介しました。
Convex と Clerk を併用することで、認証付きのフルスタックアプリを非常に簡単に実装することができます。
今回のサンプルコードは、以下の GitHub リポジトリで公開しています。
最後までお読みいただきありがとうございました!
Discussion
とてもわかりやすい記事ありがとうございます。
ryosukeと申します!
こちらの記事の途中でconvex-client-provider.tsファイルを作成していると思うのですがこちらtsxファイルでしょうか...?
もし間違った指摘をしてしまっていたら申し訳ございません!
@ryosuke
お読みいただきありがとうございます。
ご指摘ありがとうございます。
おっしゃる通り、拡張子が間違っておりました。正しくは
convex-client-provider.tsx
となります。修正しました。