iTranslated by AI
Easily Build Authenticated Full-Stack Apps with Next.js, Convex, and Clerk
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.
What is Convex
Convex is a backend application platform like Firebase. It provides features such as real-time databases and file storage.
The differences from Firebase are explained in detail in the following article.
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.
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.

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

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.

Once created, the environment variables will be displayed. Copy them and paste them into .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:
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.
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.
"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.
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!):
"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.
@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>! 👀
"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.
Logging Out
Setting up a logout button is also easy. Simply add the <SignOutButton> as follows.
"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:
"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.
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.
Using the Database (Convex)
Defining the Schema
Create convex/schema.ts. Here, we will create a table named 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"]),
});
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.
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.
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.
Creating the POST API
Next, create a POST API. We'll create it with the name 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;
},
});
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.
The code for `convex/messages.ts` so far will look like this:
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.
"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;
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.
Thank you for reading to the end!
Discussion
とてもわかりやすい記事ありがとうございます。
ryosukeと申します!
こちらの記事の途中でconvex-client-provider.tsファイルを作成していると思うのですがこちらtsxファイルでしょうか...?
もし間違った指摘をしてしまっていたら申し訳ございません!
@ryosuke
お読みいただきありがとうございます。
ご指摘ありがとうございます。
おっしゃる通り、拡張子が間違っておりました。正しくは
convex-client-provider.tsxとなります。修正しました。