🔨

Next.js × Convex × Clerkで認証付きフルスタックアプリをラクに開発する

2024/01/21に公開
2

はじめに

この記事では、Next.js × Convex × Clerk でフルスタックアプリを作る方法をハンズオン形式で紹介します。
Convex と Clerk を組み合わせることで、認証付きのフルスタックアプリを爆速で開発することができます。

完成物は、以下の GitHub リポジトリで公開しています。

https://github.com/yu-3in/nextjs-convex-clerk-sample

Convex とは

Convex は、Firebase のようなバックエンドアプリケーションプラットフォームです。リアルタイムデータベースやファイルストレージなどの機能を提供しています。

https://convex.dev/

Firebase との違いは、以下の記事で詳しく解説されています。

https://stack.convex.dev/convex-vs-firebase

Clerk とは

Clerk は、認証・認可を提供するサービスです。Clerk と Convex を併用することで、認証・認可の実装を非常に簡単に行うことができます。

https://clerk.com/

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のトップページ

続いて、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 が記載されています。

.env.local
# 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 をクリックします。

Clerkのダッシュボード画面

モーダルが表示されるので、 Application name に任意の名前を(ここでは nextjs-convex-clerk-sample)、Google ログインのみチェックを入れて、右下の Create applicationボタンをクリックします。

Clerkの新規プロジェクトを作成する

作成が完了すると環境変数が表示されるので、それらをコピーして .env.local に貼り付けます。

.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx
CLERK_SECRET_KEY=sk_test_xxx

ターミナルに戻り、Clerk をインストールします。

$ npm install @clerk/nextjs

Convex と Clerk を連携する

最後に、Convex と Clerk を連携します。
こちらの公式の記事も参考になります。

https://docs.convex.dev/auth/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 の値を入力してください。

convex/auth.config.js
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を作成します。

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を追加します。

app/layout.tsx
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"; を忘れないでください!)

app/page.tsx
"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 は使うので、そのまま残しておきます。

app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;

ログインボタンを設置する

本題のソーシャルログイン機能を実装します。といっても一瞬です。 <SignInButton>を追加するだけです!👀

app/page.tsx
"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 を使うと最小限のコードでスマートな認証機能を実装することができます。

https://clerk.com/docs/components/overview

ログアウトする

ログアウトボタンの設置も簡単です。次のように <SignOutButton>を追加します。

app/page.tsx
"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を以下のように編集します。

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点解説します。

1. useConvexAuth

useConvexAuthは、Convex の認証ステートを取得するフックです。

  • isAuthenticated:ユーザが認証済みかどうかを表す真偽値です。
  • isLoading :認証ステートのフェッチが完了しているかどうかを表す真偽値です。

https://docs.convex.dev/api/modules/react#functions

2. useUser

useUserは、Clerk の現在ログインしているユーザ情報を取得するフックです。userの他にisSignedInisLoadedを返します。

https://clerk.com/docs/references/react/use-user

データベースを使う(Convex)

schema を定義する

convex/schema.tsを作成します。ここでは、messagesというテーブルを作成します。

convex/schema.ts
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 の作成について、詳しくは公式ドキュメントを参照してください。

https://docs.convex.dev/database/schemas

API を作成する

続いて、convex/messages.tsを作成します。ここでは、messagesテーブルの CRUD API を作成します。

GET API を作成する

まず、GET API を作成します。getAllという名前で作成します。

convex/messages.ts
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でインデックスを指定します。インデックスを指定することで、クエリのパフォーマンスが向上します。

他にも様々なクエリが用意されています。詳しくは、公式ドキュメントを参照してください。

https://docs.convex.dev/database/reading-data

POST API を作成する

続いて、POST API を作成します。createという名前で作成します。

convex/messages.ts
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 に渡す引数を定義します。ここでは、titlecontentを定義しています。

v.id はバリデーターと呼ばれるものです。条件に合致しない場合はエラーを返します。v.id("messages")は、messagesテーブルの ID であることを表しています。
呼び出し側に対しても型情報が提供されるため、型安全にコーディングすることができます。

https://docs.convex.dev/functions/args-validation

ここまでの `convex/messages.ts` のコードは、以下のようになります。
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.tsxapp/messages/page.tsxを作成します。

app/messages/layout.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;
app/messages/page.tsx
"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);

useQueryuseMutationは、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 リポジトリで公開しています。

https://github.com/yu-3in/nextjs-convex-clerk-sample

最後までお読みいただきありがとうございました!

GitHubで編集を提案

Discussion

りょうすけりょうすけ

とてもわかりやすい記事ありがとうございます。
ryosukeと申します!

こちらの記事の途中でconvex-client-provider.tsファイルを作成していると思うのですがこちらtsxファイルでしょうか...?

もし間違った指摘をしてしまっていたら申し訳ございません!

yuuuminyuuumin

@ryosuke
お読みいただきありがとうございます。

こちらの記事の途中でconvex-client-provider.tsファイルを作成していると思うのですがこちらtsxファイルでしょうか...?

ご指摘ありがとうございます。
おっしゃる通り、拡張子が間違っておりました。正しくは convex-client-provider.tsx となります。
修正しました。