iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🔨

Easily Build Authenticated Full-Stack Apps with Next.js, Convex, and Clerk

に公開
2

Introduction

In this article, I will introduce how to build a full-stack app with Next.js × Convex × Clerk in a hands-on format.
By combining Convex and Clerk, you can develop full-stack apps with authentication at lightning speed.

The finished product is published in the following GitHub repository.

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

What is Convex

Convex is a backend application platform like Firebase. It provides features such as real-time databases and file storage.

https://convex.dev/

The differences from Firebase are explained in detail in the following article.

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

What is Clerk

Clerk is a service that provides authentication and authorization. By using Clerk and Convex together, implementing authentication and authorization becomes extremely easy.

https://clerk.com/

When thinking about authentication in Next.js, NextAuth often comes to mind. With NextAuth, you need to write middleware and implement APIs for authentication. You also have to create the login page from scratch. These can be stumbling blocks if you don't have a deep understanding of authentication, and they also take time to implement.
However, with Clerk, this is not necessary. Since Clerk provides Hooks and UI related to authentication, you can easily implement authentication features even without a deep understanding of it.

Tech Stack

  • Next.js v14.1.0 (App router)
  • Convex
  • Clerk
  • (Tailwind CSS)

Setup

Next.js Setup

First, create a Next.js project. You can leave all the options as default.

$ npx create-next-app@14.1.0 nextjs-convex-clerk-sample

Navigate to the created project.

$ cd nextjs-convex-clerk-sample

Start the development server.

$ npm run dev

Convex Setup

First, access convex.dev and log in with GitHub.

Convex top page

Next, set up Convex. Open a new terminal and run the following command (do not stop npm run dev).

$ npm install convex

Running the following command will handle project creation/selection and start the development backend server.
If you are not logged in with GitHub, you will be prompted to log in.

$ npx convex dev

Choices will be displayed. Select a new project here. A new project will be created, so enter its name.

$ npx convex dev
? What would you like to configure? a new project
? Project name: (nextjs-convex-clerk-sample)

Once the creation is complete, .env.local will be created. This file contains the URL of the Convex development backend server.

.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 Setup

Next, set up Clerk.
First, access clerk.com and log in.

Once logged in, you will be redirected to the dashboard. Click Add application here.

Clerk dashboard screen

A modal will appear. Enter any name in Application name (here, nextjs-convex-clerk-sample), check only Google login, and click the Create application button at the bottom right.

Create a new project in Clerk

Once created, the environment variables will be displayed. Copy them and paste them into .env.local.

.env.local
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=xxx
CLERK_SECRET_KEY=sk_test_xxx

Return to the terminal and install Clerk.

$ npm install @clerk/nextjs

Integrating Convex and Clerk

Finally, integrate Convex and Clerk.
The following official article is also helpful:

https://docs.convex.dev/auth/clerk

Creating a JWT Template

Select "JWT Template" from the menu again to go to the following page.
Click New Template and select Convex from the options displayed.

The JWT Template setting screen will appear. You don't need to change any settings.
However, the "Issuer" value will be used in later settings, so please copy it.

Once copied, click Apply Changes at the bottom right.

Create convex/auth.config.js

Create convex/auth.config.js. In this file, describe the JWT Template settings.
In https://your-issuer-url.clerk.accounts.dev/, enter the Issuer value you just copied.

convex/auth.config.js
export default {
  providers: [
    {
      domain: "https://your-issuer-url.clerk.accounts.dev/",
      applicationID: "convex",
    },
  ]
};

Finally, run npx convex dev again.

$ npx convex dev

Create convex-client-provider

Create a providers directory at the same level as the app directory. Next, create convex-client-provider.tsx in the providers directory.

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>
  );
}

Go to app/layout.tsx and add 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>
  );
}

This completes the setup for Next.js, Convex, and Clerk.

Implementing Social Login Features

First, open app/page.tsx. Delete everything for now and rewrite it as follows (don't forget the "use client"; at the beginning!):

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>
  );
}

At the same time, reset app/globals.css. We will use Tailwind CSS, so keep the base directives.

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

Setting Up the Login Button

Now for the main topic: implementing the social login feature. Actually, it takes only a moment—you just need to add <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>
  );
}

Access http://localhost:3000. When you click the displayed "ログイン" (Login) button, the login UI will appear. This is provided out of the box by Clerk!

Clicking Continue with Google will display the Google login screen. Once you log in with your Google account, the authentication process is complete.

Clerk provides many other components. As you can see, by using Clerk along with the compatible Convex, you can implement smart authentication features with minimal code.

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

Logging Out

Setting up a logout button is also easy. Simply add the <SignOutButton> as follows.

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>
  );
}

If it is displayed as follows, it's a success! 🎉

Fetching User Information

As it is, we cannot reflect the login status in the UI. So, let's try fetching the user information.

Edit app/page.tsx as follows:

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>
  );
}

Access http://localhost:3000 again and log in; the account name will be displayed as shown below.

Let me explain two points regarding the code.

1. useConvexAuth

useConvexAuth is a hook to fetch the Convex authentication state.

  • isAuthenticated: A boolean representing whether the user is authenticated.
  • isLoading: A boolean representing whether the fetching of the authentication state is complete.

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

2. useUser

useUser is a hook to fetch the currently logged-in user information from Clerk. It returns user, as well as isSignedIn and isLoaded.

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

Using the Database (Convex)

Defining the Schema

Create convex/schema.ts. Here, we will create a table named 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"]),
});

Once defined, a log like the following will be displayed in the terminal where npx convex dev is running.

 Schema validation complete.

This automatically creates the database.

Access dashboard.convex.dev and select nextjs-convex-clerk-sample. You can confirm that messages is displayed.

For more details on creating schemas, please refer to the official documentation.

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

Creating the API

Next, create convex/messages.ts. Here, we will create the CRUD API for the messages table.

Creating the GET API

First, create a GET API. We'll create it with the name 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;
  },
});

Here are some explanations.

const identity = await ctx.auth.getUserIdentity();
if (!identity) {
  throw new Error("UnAuthorized");
}
const userId = identity.subject;

Here, authentication is being performed. If the user is not logged in, it returns an "UnAuthorized" error. Also, identity.subject is the ID of the logged-in user.

const messages = await ctx.db
  .query("messages")
  .withIndex("by_user", (q) => q.eq("userId", userId))
  .order("desc")
  .collect();

ctx.db.query creates a query. withIndex specifies an index. Specifying an index improves query performance.

There are various other queries available. For more details, please refer to the official documentation.

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

Creating the POST API

Next, create a POST API. We'll create it with the name 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;
  },
});

Just like the GET API, data is inserted using ctx.db.insert after performing authentication.

args: {
  title: v.string(),
  content: v.optional(v.string()),
},

args defines the arguments to be passed to the API. Here, title and content are defined.

v.id is what's called a validator. It returns an error if the condition is not met. v.id("messages") indicates that it must be an ID from the messages table.
Since type information is also provided to the caller, you can code in a type-safe manner.

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

The code for `convex/messages.ts` so far will look like this:
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;
  },
});

Calling the API

Finally, let's try calling the API we just created.

In this step, we will create a new page at /messages. Create the files app/messages/layout.tsx and app/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;

Let's explain the code.

const messages = useQuery(api.messages.getAll);

useQuery and useMutation are hooks for calling APIs. useQuery calls the API and fetches its results.

const create = useMutation(api.messages.create);
...
await create({ title, content });

useMutation is used to call the API and trigger an action. create is a function used to call api.messages.create. By passing arguments to create, you can send data to the API.

When you access http://localhost:3000/messages, a screen like the following will be displayed.

After filling out the form and creating a message, it will be displayed as shown below 🎉

Conclusion

In this article, I introduced how to easily develop a full-stack app with Next.js × Convex × Clerk.
By combining Convex and Clerk, you can implement a full-stack app with authentication very easily.

The sample code for this project is available in the following GitHub repository.

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

Thank you for reading to the end!

GitHubで編集を提案

Discussion

りょうすけりょうすけ

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

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

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

yuuuminyuuumin

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

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

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