Using Clerk authentication for a fullstack project
1. What We Will Talk About:
Most tutorials out there for using clerk authentication do not cover a client/server model. In this tutorial I cover how to get started with clerk on the frontend, how to connect with a database using webhooks in the backend, and how to authenticate requests from the frontend using middleware in the backend.
2. Setting Up The Clerk Dashboard
- Create an account on Clerk's website
- Navigate to the dashboard and create a new Organization and Application Name
For this example we will just have google and email selected; However, most of the sign-in options are easy to integrate if you choose to select them.
3. Setting Up The Frontend
- Start by creating a new next.js project with this configuration.
npx create-next-app@latest
What is your project named? my-app
Would you like to use TypeScript? No / (Yes)
Would you like to use ESLint? No / (Yes)
Would you like to use Tailwind CSS? No / (Yes)
Would you like to use `src/` directory? No / (Yes)
Would you like to use App Router? (recommended) No / (Yes)
Would you like to customize the default import alias (@/*)? (No) / Yes
- The next part is easy and comes directly from your Clerk Dashboard. Make sure to just follow steps 1-3.
- Continue by removing some of the example code, this is what I am left with ...
// src/app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
// src/app/page.tsx
export default function Home() {
return (
<main className="w-full flex items-center justify-center mt-10">
<h1>Hello World</h1>
</main>
);
}
- In the layout.tsx page we are going to initialize a .tsx page for the React Query Provider, (TanStackQuery).
- First install necessary dependencies
npm i @tanstack/react-query axios clsx
- Create the ReactQueryProvider.tsx page.
// src/providers/ReactQueryProvider.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient();
export const ReactQueryProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
- Now we can update the layout.tsx page.
// src/app/layout.tsx
import { Inter, Lexend } from "next/font/google";
import clsx from "clsx";
import "./globals.css";
import { type Metadata } from "next";
import { ReactQueryProvider } from "@/providers/ReactQueryProvider";
import { ClerkProvider } from "@clerk/nextjs";
export const metadata: Metadata = {
title: {
template: "%s - Clerk Fulltack Tutorial",
default: "Clerk Fulltack Tutorial - connecting the client and server",
},
description:
"A project that connects the power of clerk effieciently to your fullstack workflow",
};
const inter = Inter({
subsets: ["latin"],
display: "swap",
variable: "--font-inter",
});
const lexend = Lexend({
subsets: ["latin"],
display: "swap",
variable: "--font-lexend",
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<ReactQueryProvider>
<html
lang="en"
className={clsx(
"h-full scroll-smooth bg-white antialiased",
inter.variable,
lexend.variable,
)}
>
<body className="">{children}</body>
</html>
</ReactQueryProvider>
</ClerkProvider>
);
}
-
By default Clerk will host the sign in and sign up pages for you but I recommend hosting them locally, more on this here
-
Create the NavBar.tsx page, this will be used to access the Clerk sign on page.
// src/components/NavBar.tsx
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs";
const NavBar = () => {
return (
<nav className="h-10 flex items-center justify-center w-full shadow-lg">
<SignedIn>
<UserButton />
</SignedIn>
<SignedOut>
<SignInButton>
<button>Sign in</button>
</SignInButton>
</SignedOut>
</nav>
);
};
export default NavBar;
- Insert this component in the layout.tsx page.
// src/app/layout.tsx
// ... other imports
import NavBar from "@/components/NavBar";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ClerkProvider>
<ReactQueryProvider>
<html
lang="en"
className={clsx(
"h-full scroll-smooth bg-white antialiased",
inter.variable,
lexend.variable,
)}
>
<body className="">
<NavBar /> // <- insert here
{children}
</body>
</html>
</ReactQueryProvider>
</ClerkProvider>
);
}
- If you followed correctly up to this point you should be able to access the sign in page.
4. Setting Up The Backend
- In a separate directory we are going initialize a node project for our server.
npm init -y
- We will need to install dependencies for working with Express, MongoDB, and Typescript.
npm i @clerk/clerk-sdk-node svix @typegoose/typegoose @types/mongoose axios cors dotenv express mongoose
npm i -D @types/cors @types/express @types/node @typescript-eslint/eslint-plugin eslint eslint-config-prettier eslint-plugin-prettier nodemon prettier ts-node typescript
- These are my configs, feel free to steal them.
// tsconfig.json
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"target": "es2016",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"rootDir": "./src",
"outDir": "./dist",
"skipLibCheck": true
}
}
// .eslintrc.json
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:prettier/recommended"
],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module",
"project": "./tsconfig.json"
},
"plugins": ["@typescript-eslint", "prettier"],
"rules": {
"prettier/prettier": [
"error",
{
"singleQuote": false,
"semi": true,
"trailingComma": "all",
"printWidth": 80
}
],
"@typescript-eslint/explicit-module-boundary-types": "off",
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error"],
"@typescript-eslint/no-explicit-any": "off"
},
"env": {
"node": true,
"es6": true
}
}
# .gitignore
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
/dist
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts
// package.json
{
// {...},
"scripts": {
"dev": "nodemon dist/index.js",
"start": "node dist/index.js",
"postinstall": "tsc",
"watch-ts": "tsc -w",
"lint": "eslint 'src/**/*.{js,ts}'",
"lint:fix": "eslint 'src/**/*.{js,ts}' --fix",
"format": "prettier --write 'src/**/*.{js,ts,json,md}'"
},
}
// src/config.ts
import dotenv from "dotenv";
dotenv.config();
const HOST = process.env.HOST;
const FRONTEND = process.env.FRONTEND as string;
const PORT = process.env.PORT;
const MONGO_URI = process.env.MONGO_URI;
const CLERK_WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET as string;
export const config = {
app: {
host: HOST,
port: PORT,
logLevel: "info",
frontend: FRONTEND,
},
clerk: {
webhook_secret: CLERK_WEBHOOK_SECRET,
},
db: {
uri: MONGO_URI,
},
};
- Create an index.ts page in your src directory, in this page we will init the express server and handle cors.
import express, { Express, Request, Response } from "express";
import cors from "cors";
import { config } from "./config";
const app: Express = express();
const port = config.app.port;
const host = config.app.host;
app.get("/", (_req: Request, res: Response) => {
res.send("Express + TypeScript");
});
app.use(
cors({
origin: [config.app.frontend, "https://clerk.dev"],
credentials: true,
allowedHeaders: [
"Origin",
"X-Requested-With",
"Content-Type",
"Accept",
"Authorization",
],
}),
);
app.use(express.json());
app.listen(port, () => {
console.log(`[server]: Server is running at ${host}:${port}`);
});
- Populate your .env file
HOST="localhost"
PORT=4000
FRONTEND="https://localhost:3000"
- Check to see if everything is running correctly, open two terminals and run these commands separatly in your root server directory. You should get a similar response
npm run watch-ts
[11:33:05 AM] File change detected. Starting incremental compilation...
[11:33:05 AM] Found 0 errors. Watching for file changes.
npm run dev
> server@1.0.0 dev
> nodemon dist/index.js
[nodemon] 3.1.4
[nodemon] to restart at any time, enter `rs`
[nodemon] watching path(s): *.*
[nodemon] watching extensions: js,mjs,cjs,json
[nodemon] starting `node dist/index.js`
[server]: Server is running at localhost:4000
- Open a browser at your port, you should see this message below.
5. Creating The Webhook
- For the next section we will be using ngrok
- Ngrok will allow us to test our webhook on our local machine by port fowarding the temporary domain provided and hosted by ngrok to our local machine.
- Once you have an account and have ngrok installed on your local machine by follwing the simple instructions in your ngrok dashboard, create a new domain here.
- Now you can start an ssh tunnel, note you will need to change the domain to the one provided in your ngrok dashbord. The port should match the port you set up in your .env file.
ngrok http --domain=major-loudly-gannet.ngrok-free.app 4000
- Copy the https endpoint provided when you run this command, it should look like this.
- Navigate back to the Clerk dashboard and on the left hand side under configure you will find a section labled Webhooks.
- Continue to paste the previously copied https endpoint from your terminal into the "Endpoint URL". Make sure to append "/webhooks" to your URL, this is the location we will set our endpoint in our server.
- Subscribe to the user.created and user.updated events, this will allow clerk to know when to call this endpoint, when a user is created or updated. Then click on Create Webhook.
- Continue by grabbing the Webhook Signing Secret generated after you created your endpoint it should be located near the right hand side.
- Paste this into your .env file.
// other variables
CLERK_WEBHOOK_SECRET="whsec ...your_secret"
- Continue by updating your index.ts page in your server to handle the webhook request.
// index.ts
// ... other imports.
import { Webhook } from "svix";
import bodyParser from "body-parser";
// ... other code
app.cors({...})
app.post(
"/webhooks",
bodyParser.raw({ type: "application/json" }),
async function (req: Request, res: Response) {
try {
const payloadString = req.body.toString();
const svixHeaders = req.headers as Record<string, string>;
const wh = new Webhook(config.clerk.webhook_secret as string);
const evt = wh.verify(payloadString, svixHeaders as any) as any;
const { id, ...attributes } = evt.data;
// Handle the webhooks
const eventType = evt.type;
const firstName = attributes.first_name;
const lastName = attributes.last_name;
const email = attributes.email_addresses.find(
(_: any) => attributes.primary_email_address_id === _.id,
)?.email_address;
console.log({ id, firstName, lastName, email, eventType });
return res.status(200).json({
success: true,
message: "Webhook received",
});
} catch (err) {
console.error((err as Error).message || "Unknown error");
return res.status(400).json({
success: false,
message: (err as Error).message || "Unknown error",
});
}
},
);
-
It is now time to test the endpoint!
-
Navigate to your sign-in page on your client website and create an account, once you have done so you will see a user button in your home page.
-
You should also see a similar message in your server console, with the email you logged in with.
[server]: Server is running at localhost:4000
{
id: 'user_2iZO00fCgCdXZBMcKjeHQglTWpg',
firstName: 'John',
lastName: 'Doe',
email: 'johndoe@example.com',
eventType: 'user.created'
}
6. Connecting MongoDB
-
Now that we have a webhook that is called for every user created or updated, it is time to connect MongoDB. This will be helpful so we can connect Clerk with our server side logic.
-
For this tutorial we will create a user model and add it to a free database cluster on MongoDB.
-
Navigate to the MongoDB website and create an account.
-
Create a New Project and name it whatever you would like.
- You will be redirected to the Project Dashboard, from here just click on create new cluster
- Select the shared free cluster and whatever zone suits you as well as a name for the cluster, then click Create Deployment.
- Create a MongoDB database user, you can select your own password or copy the one given to you.
- While not necessary, I recommend follwing the instructions to use MongoDB Compass. This will provide an app you can run on your local machine to view your database when you insert the connection string.
- Copy and paste the connection string to your .env file.
//other variables
MONGO_URI="your_connection_string"
- In your project dashboard on the left hand side click on network access, Once here create a new entry and allow access from anywhere.
- This is generally not recommended, so I have it set to temporary and will eventully add my production IP Address when I dedploy.
- Connect MongoDB to your server by inserting this code block into the index.ts page.
// ... other imports
import { mongoose } from "@typegoose/typegoose";
// ... other code
app.use(express.json());
mongoose
.connect(config.db.uri as string)
.then(() => {
console.log("MongoDB connected");
})
.catch((err) => console.log(err));
- Continue by restarting your server, you should see this message.
[server]: Server is running at localhost:4000
MongoDB connected
7. Adding User With Clerk Webhook
- We will create a user when ever the Clerk webhook is called.
- Start by creating a user.model.ts page to represent the user model we need to create on each request.
- In the model I will just store some basic information that the Clerk webhook gives you by default when logging in with google.
- I will also include some logic to randomly select a favorite color for each user created, this will be important later.
// src/modules/user/user.model.ts
import {
buildSchema,
mongoose,
prop,
modelOptions,
Severity,
} from "@typegoose/typegoose";
// Define the enum
enum Colors {
RED = "RED",
GREEN = "GREEN",
BLUE = "BLUE",
}
// Function to get a random enum value
function getRandomEnumValue<T>(enumType: T): T[keyof T] | undefined {
const enumValues = Object.values(enumType as Enumerator) as Array<T[keyof T]>;
const randomIndex = Math.floor(Math.random() * enumValues.length);
const value = enumValues[randomIndex];
return value;
}
@modelOptions({
schemaOptions: {
timestamps: true,
collection: "users",
},
options: {
allowMixed: Severity.ALLOW,
},
})
export class User {
@prop({ required: true, unique: true })
public clerkUserId!: string;
@prop({ required: true })
public email!: string;
@prop({ required: true })
public firstName!: string;
@prop({ required: true })
public lastName!: string;
@prop({ default: () => getRandomEnumValue(Colors) })
public favoriteColor?: Colors;
}
const UserModel = mongoose.connection
.useDb("auth")
.model("users", buildSchema(User));
export default UserModel;
- Update the index.ts page to include user model in the webhook.
// ... other imports
import UserModel from "./modules/user/user.model";
// ... other code
app.post(
"/webhooks",
bodyParser.raw({ type: "application/json" }),
async function (req: Request, res: Response) {
try {
const payloadString = req.body.toString();
const svixHeaders = req.headers as Record<string, string>;
const wh = new Webhook(config.clerk.webhook_secret as string);
const evt = wh.verify(payloadString, svixHeaders as any) as any;
const { id, ...attributes } = evt.data;
// Handle the webhooks
const eventType = evt.type;
const firstName = attributes.first_name;
const lastName = attributes.last_name;
const email = attributes.email_addresses.find(
(_: any) => attributes.primary_email_address_id === _.id,
)?.email_address;
console.log({ id, firstName, lastName, email, eventType });
if (eventType === "user.created") {
const user = new UserModel({
clerkUserId: id,
firstName: firstName,
lastName: lastName,
email: email,
});
await user.save();
}
if (eventType === "user.updated") {
await UserModel.findOneAndUpdate(
{ clerkUserId: id },
{
firstName: firstName,
lastName: lastName,
email: email,
},
);
}
return res.status(200).json({
success: true,
message: "Webhook received",
});
} catch (err) {
console.error((err as Error).message || "Unknown error");
return res.status(400).json({
success: false,
message: (err as Error).message || "Unknown error",
});
}
},
);
- Navigate to your Clerk Dashboard, on the left hand side under Users, remove the current user.
- Navigate to your client browser and create a new account again.
- You should see the same success log reponse in your server.
- Now check your MongoDB Compass you should have a new entry. Note that a randomly selected favorite color field was created.
8. Create Protected Route On The Server
- Navigate to your Clerk Dashboard, on the left hand side click on API Keys.
- Copy and paste these variables into your server .env file.
CLERK_SECRET_KEY="sk_test_your_key"
CLERK_PUBLISHABLE_KEY="pk_test_your_key"
- Next we need to create the validateUser.ts middleware to valide the user.
// src/middleware/validateUser.ts
import { NextFunction, Request, Response } from "express";
import UserModel from "../modules/user/user.model";
export const ValidateUser = async (
req: Request,
res: Response,
next: NextFunction,
) => {
try {
const { userId } = req.auth;
const user = await UserModel.findOne({ clerkUserId: userId });
if (!user) return res.status(404).send("User not found");
res.locals.favoriteColor = user.favoriteColor as string;
return next();
} catch (e: any) {
return res.status(500).send("Something Went Wrong On Server");
}
};
- Next we got to create the controller to send the response to the frontend.
// src/modules/user/user.controller.ts
import { Request, Response } from "express";
export const GetUserFavoriteColor = async (_req: Request, res: Response) => {
try {
return res.status(200).send({
payload: res.locals.favoriteColor,
message: "Successfully Got User Favorite Color.",
});
} catch (e: any) {
return res.status(500).send("Something Went Wrong On Server");
}
};
- Next we will create the route for the user.
// src/modules/user/user.route.ts
import "dotenv/config";
import express from "express";
import { ClerkExpressRequireAuth } from "@clerk/clerk-sdk-node";
import { ValidateUser } from "../../middleware/validateUser";
import { GetUserFavoriteColor } from "./user.controller";
declare module "express" {
export interface Request {
auth?: any;
}
}
const router = express.Router();
router.get(
"/user/favoriteColor",
ClerkExpressRequireAuth(),
ValidateUser,
GetUserFavoriteColor,
);
export default router;
- Now we need to update the index.ts page to use the route.
// ... other imports
import userRouter from "./modules/user/user.route";
// ... other code
app.use(express.json());
app.use(userRouter);
9. Access Protected Route On The Frontend.
- Copy and paste the backend variable into your .env file.
NEXT_PUBLIC_BACKEND=http://localhost:4000
- Create the MyFavoriteColor page to access the protected route.
// src/app/my-favorite-color/page.tsx
"use client";
import { SignedIn, SignedOut, useAuth } from "@clerk/nextjs";
import { useQuery } from "@tanstack/react-query";
import axios from "axios";
import { useCallback } from "react";
interface FavoriteColorResponse {
payload: string;
message: string;
}
const MyFavoriteColor = () => {
const { getToken, isSignedIn } = useAuth();
const getFavoriteColor = useCallback(async () => {
const res = await axios.get(
`${process.env.NEXT_PUBLIC_BACKEND}/user/favoriteColor`,
{
headers: { Authorization: `Bearer ${await getToken()}` },
},
);
return res.data;
}, [getToken]);
const getFavoriteColorQuery = useQuery<FavoriteColorResponse>({
queryKey: ["files"],
queryFn: getFavoriteColor,
enabled: isSignedIn,
refetchOnWindowFocus: false, // Prevent refetching on window focus
refetchOnMount: false, // Prevent automatic refetching on component mount
refetchInterval: false, // Prevent automatic refetching at intervals
refetchIntervalInBackground: false, // Prevent automatic refetching in background
staleTime: Infinity, // Prevent automatic query invalidation based on time
});
return (
<main className="mt-10 flex items-center justify-center">
<SignedIn>
<>
{getFavoriteColorQuery.isError && (
<h1>Something went wrong in the server.</h1>
)}
{(getFavoriteColorQuery.isLoading ||
getFavoriteColorQuery.isRefetching) && (
<h1>Fetching favorite color...</h1>
)}
{!getFavoriteColorQuery.isLoading &&
!getFavoriteColorQuery.isRefetching &&
getFavoriteColorQuery.isSuccess && (
<h1>
Your Favorite Color is: {getFavoriteColorQuery.data.payload}
</h1>
)}
</>
</SignedIn>
<SignedOut>
<h1>You need to be signed in to view this page.</h1>
</SignedOut>
</main>
);
};
export default MyFavoriteColor;
- Now open your client browser at the /my-favorite-color endpoint.
Checkout the github repo if you get lost. I look foward to making more of these articles and I will hopefully start making youtube videos going more in detail in my projects soon.