Authentication in Next.js with Clerk.com and trpc.io

When I started out as a dev, authentication always felt a little bit intimidating. So many new concepts to learn, so many new libraries to install, so many new things to configure. And there is the angst of implementing an unsecure system. Just to finally end up with a system that allows users to login. It was a pain.

Don't get me wrong, struggling is good. It's how we learn. And there is definitely a benefit if you've implemented authentication a couple of times from scratch. But roling our own authentication comes with a downside. You have to maintain it. And if you're like me, you'll probably have more interesting problems to solve then keeping our authentication system up to date. So as I said, if you're interested in how stuff works, do it yourself. But trust me, it will become a pain in the ass, especially if you're working on more projects. Anyway, let's dive in. By the way, all the code can be found here. It's this page - manuelbichler.com

Tech stack

  • Next.js
  • trpc.io
  • Clerk.com

Setting things up

Im not gonna write about setting up Next.js nor trpc. This is only about adding authentication with Clerk.com. The basic workflow is.

  1. Register at Clerk.com and install npm i @clerk/nextjs
  2. Add AuthProvider for public and protected pages
  3. Add Sign-in and Sign-out buttons/components
  4. Add middleware to use auth in trpc
  5. Add auth to trpc context
  6. Protect our routers/procedures with auth/authorization
  7. Add a webhook to update our database when user details change in clerk.com
  8. Add another field to our JWT token
  9. Add social connection provider for productio - Google
  10. Time to be 'Happy, peppy and cheerful' 🎉

1. Register at Clerk.com and install package

Go to Clerk.com/register, install the package npm i @clerk/nextjs and copy our api keys in our .env.local file.

2. Add AuthProvider with public and protected routes

As with almost everything in React that you'll need in our whole app, needs to be a context provider. It's basically our parent giving information to all children which are our Components. First I'll give you the basic version of the _app.tsx. Further downn you'll see a more realistic version.

// src/pages/_app.tsx
import { ClerkProvider } from '@clerk/nextjs'
import type { AppProps } from 'next/app'

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <ClerkProvider {...pageProps}>
      <Component {...pageProps} />
    </ClerkProvider>
  )
}

export default MyApp

This gives you access to the Clerk context in all our components. But you still need to add the sign-in and sign-out buttons/pages. So let's do that first.

3. Add Sign-in and Sign-out buttons/components

Here we add a simple sign-up modal on the /sign-up page and a sign-out button on the Profiledropdownmenu in the second step.

import { SignUp } from '@clerk/nextjs'

const SignUpPage = () => (
  <div className="relative my-20 flex justify-center">
    <SignUp path="/sign-up" routing="path" signInUrl="/sign-in" />
  </div>
)
export default SignUpPage

The path tells clerk when/where it should mount the component. In our case when the user is on the /sign-up page. The routing prop tells clerk how to handle the routing. We are using path routing. Default would be hash based routing. The signInUrl is just the link for the correct singInUrl in yor app. The link at the bottom of the component. The SignIn is basically the same.

// src/pages/signin/[[...index]].tsx
import { SignIn } from '@clerk/nextjs'

const SignInPage = () => (
  <div className="relative my-20 flex justify-center">
    <SignIn path="/sign-in" routing="path" signUpUrl="/sign-up" />
  </div>
)

export default SignInPage

In Next.js, the ... notation within the file name (specifically [[...index]].tsx in this case) represents a catch-all route. Catch-all routes allow you to match and handle multiple dynamic segments (route parameters) in a single file.

For instance, in the /pages/signin/[[...index]].tsx file, the [[...index]] part means that this file will handle any number of dynamic segments after /signin/ in the URL.

Some examples of URL patterns that this catch-all route will match: /signin, /signin/user, /signin/user/123, /signin/user/123/edit,...

In the [[...index]].tsx file, you can access these dynamic segments using the useRouter hook from the next/router module.

Let's continue with the logout functionality. We add a profile button for the logout that looks something like this.

//src/header/ProfileButton.tsx
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { routes } from '@/utils/routes'
import { useClerk, useUser } from '@clerk/nextjs'
import AvatarMarble from 'boring-avatars'
import { LifeBuoy, LogOut, User } from 'lucide-react'
import Link from 'next/link'
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar'

export function ProfileButton() {
  const { isSignedIn, user, isLoaded } = useUser()
  // get the signOut function and openSignIn modal function from clerk
  const { signOut, openSignIn } = useClerk()

  return (
    <>
      {isSignedIn ? (
        <DropdownMenu>
          <DropdownMenuTrigger asChild>
            <button className="relative h-10 w-10 rounded-full">
              <span className="sr-only">Open user menu</span>
              <Avatar>
                {user?.profileImageUrl.includes('gravatar') ? (
                  <AvatarFallback>
                    <AvatarMarble
                      size={34}
                      name="Maria Mitchell"
                      variant="marble"
                      colors={['#2E9E94', '#2E709E', '#3AC5B9', '#A1A1AA']}
                    />
                  </AvatarFallback>
                ) : (
                  <AvatarImage
                    src={user?.profileImageUrl}
                    alt={user?.username ?? 'username'}
                  />
                )}
              </Avatar>
            </button>
          </DropdownMenuTrigger>
          <DropdownMenuContent className="w-56" align="end" forceMount>
            <DropdownMenuLabel>My Account</DropdownMenuLabel>
            <DropdownMenuSeparator />
            <DropdownMenuGroup>
              <Link href={routes.protected.profile.add.path}>
                <DropdownMenuItem className="cursor-pointer">
                  <User className="mr-2 h-4 w-4" />
                  <span>{routes.protected.profile.add.label}</span>
                </DropdownMenuItem>
              </Link>
              <a href={`mailto:${routes.contacts.email}`} target="_blank">
                <DropdownMenuItem className="cursor-pointer">
                  <LifeBuoy className="mr-2 h-4 w-4" />
                  <span>Support</span>
                </DropdownMenuItem>
              </a>
            </DropdownMenuGroup>
            <DropdownMenuSeparator />
            // add the signOut function to the dropdown menu
            <DropdownMenuItem
              onClick={() => signOut()}
              className="cursor-pointer"
            >
              <LogOut className="mr-2 h-4 w-4" />
              <span>Log out</span>
            </DropdownMenuItem>
          </DropdownMenuContent>
        </DropdownMenu>
      ) : (
        <>
          // if the user is not signed in open the signIn modal
          <button
            className="relative text-sm font-semibold leading-6 text-zinc-800 hover:text-teal-500 dark:divide-zinc-100/5 dark:text-zinc-300 dark:hover:text-teal-400"
            onClick={() => openSignIn()}
          >
            Log in <span aria-hidden="true">&rarr;</span>
          </button>
        </>
      )}
    </>
  )
}

The next step is to let the app in the frontend check to what routes the unauthenticated user has access and for witch routes a user must be signed in. There are two possible solutions. The first solution is you can check in the _app.tsx if the user is signed in and if the page is public or not. This is recommended if you're not using nexts /pages/api routes as a backend.

// src/pages/_app.ts
import {
  ClerkProvider,
  SignedIn,
  SignedOut,
  RedirectToSignIn,
} from '@clerk/nextjs'
import { useRouter } from 'next/router'

//  List pages you want to be publicly accessible, or leave empty if
//  every page requires authentication. Use this naming strategy:
//   "/"              for pages/index.js
//   "/foo"           for pages/foo/index.js
//   "/foo*"          for all the pages inside /foo

const publicPages = []

function MyApp({ Component, pageProps }) {
  // Get the pathname
  const { pathname } = useRouter()

  // Check if the current route matches a public page
  const isPublicPage = publicPages.includes(pathname)

  // If the current route is listed as public, render it directly
  // Otherwise, use Clerk to require authentication
  return (
    <ClerkProvider
      appearance={{
        variables: { colorPrimary: '#27272A' },
        elements: {
          formFieldInput: 'rounded-md border border-zinc-900/10 bg-white',
        },
      }}
      {...pageProps}
    >
      {isPublicPage ? (
        <Component {...pageProps} />
      ) : (
        <>
          <SignedIn>
            <Component {...pageProps} />
          </SignedIn>
          <SignedOut>
            <RedirectToSignIn />
          </SignedOut>
        </>
      )}
    </ClerkProvider>
  )
}

export default MyApp

4. Add middleware to use auth in trpc

If youre like me and you're using trpc, it's easier to just add a middleware and check if a requested route is public or not. Also I changed from checking for public routes to private routes as I have only two private routes.

// src/middleware.ts
import { getAuth, withClerkMiddleware } from '@clerk/nextjs/server'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

// Set the paths that require the user to be signed in
// get the values of the path property from the routes object of all the protected routes
const privatePaths = Object.values(routes.protected).map((obj) => obj.add.path)
// Ends up in this place as `['/dashboard/add, '/flashcards/add']`
// if you want to white/blacklist a route with all it's children use `*` as a wildcard eg. `/dashboard*`
// no / before the *.
const isPrivate = (path: string) => {
  return privatePaths.find((x) =>
    path.match(new RegExp(`^${x}$`.replace('*$', '($|/)')))
  )
}

export default withClerkMiddleware((request: NextRequest) => {
  if (!isPrivate(request.nextUrl.pathname)) {
    return NextResponse.next()
  }
  // if the user is not signed in redirect them to the sign in page.
  const { userId } = getAuth(request)

  if (!userId) {
    // redirect the users to /pages/sign-in/[[...index]].ts

    const signInUrl = new URL('/sign-in', request.url)
    signInUrl.searchParams.set('redirect_url', request.url)
    return NextResponse.redirect(signInUrl)
  }
  return NextResponse.next()
})
// Stop Middleware running on static files
export const config = {
  matcher: [
    /*
     * Match all request paths except for the ones starting with:
     * - _next
     * - static (static filespath)
     * - favicon.ico (favicon file)
     * - image (image files)
     */
    '/(.*?trpc.*?|(?!static|.*\\..*|_next?|image|favicon.ico).*)',
  ],
}

5. Add auth to trpc context

Now that you have the middleware checking the routes that should be protected, we can continue with adding protection to our trpc handlers. Let's add auth to our context so we can easily access it in our endpoints.

// src/server/context.ts
import type {
  SignedInAuthObject,
  SignedOutAuthObject,
} from '@clerk/nextjs/dist/api'
import { getAuth } from '@clerk/nextjs/server'
import * as trpc from '@trpc/server'
import * as trpcNext from '@trpc/server/adapters/next'

interface AuthContext {
  auth: SignedInAuthObject | SignedOutAuthObject
}

/**
 * Inner function for `createContext` where we create the context.
 * This is useful for testing when we don't want to mock Next.js' request/response
 */
export async function createContextInner({ auth }: AuthContext) {
  return { auth }
}

export type Context = trpc.inferAsyncReturnType<typeof createContextInner>

/**
 * Creates context for an incoming request
 * @link https://trpc.io/docs/context
 */
export async function createContext(
  opts: trpcNext.CreateNextContextOptions
): Promise<Context> {
  // for API-response caching see https://trpc.io/docs/caching

  return await createContextInner({ auth: getAuth(opts.req) })
}

As we have now access to our context, we can add add middleware to our trpc endpoints. Here we will check if the user is signed in and if not we will throw an error.

// src/server/trpc.ts
// check if the user is signed in, otherwise through a UNAUTHORIZED CODE
const isAuthed = t.middleware(({ next, ctx }) => {
  if (!ctx.auth.userId) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return next({
    ctx: {
      auth: ctx.auth,
    },
  })
})

export const protectedProcedure = t.procedure.use(isAuthed)

6. Protect our routers/procedures with auth/authorization

What this allows us is to just use the new added middleware/procedure in our endpoint. Meaning only a logged in user can write a message to the guestbook.

// src/server/routers/guestbook.ts
import { z } from 'zod'
import { prisma } from '../prisma'
import {
  adminProcedure,
  protectedProcedure,
  publicProcedure,
  router,
} from '../trpc'

export const guestbookRouter = router({
  list: publicProcedure.query(async () => {
    /**
     * For pagination docs you can have a look here
     * @see https://trpc.io/docs/useInfiniteQuery
     * @see https://www.prisma.io/docs/concepts/components/prisma-client/pagination
     */

    const messages = await prisma.guestbook.findMany({
      include: { user: true },
      orderBy: {
        createdAt: 'desc',
      },
    })

    return {
      messages,
    }
  }),
  deleteAll: adminProcedure.mutation(async () => {
    const messages = await prisma.guestbook.deleteMany({})
    console.log('messagesDEL', messages)
    return messages
  }),
  add: protectedProcedure
    .input(
      z.object({
        message: z.string().min(2),
      })
    )
    .mutation(async ({ input, ctx }) => {
      console.log('ctx', ctx.auth)
      const newInput = {
        userId: ctx.auth.userId,
        message: input.message,
      }
      const message = await prisma.guestbook.create({
        data: newInput,
      })
      return message
    }),
})

7. Add a webhook to keep our database in sync

Now we have kind of a working authentication system running. For convenience reasons we will add two things. First, add a webhook to keep clerk and our database in sync. Webhooks are how services notify each other of events. Basically just ah POST request with some payload that goes to an pre-determined endpoint. Second update the JWT and session claims to add fields to our auth/session tokens.

For adding a webhook go to the Clark dashboard and click on the Webhooks link. Add a new webhook and select the User Signed In event. Then add the url to our backend. In our case it is https://api.yourdomain.com/api/webhooks/clerk/user-signed-in.

Because webhooks can be easily missused we add a library caled svix for verifying the webhook. You can read more on their documentation. For local debugging I also recommend to install their CLI.

First we need to add the endpoint to our api routes. Basically create the file src/pages/api/webhooks/updateUser.ts and then go to the Clerk dashbaord. Look for the Webhooks section and click Add Endpoint. The endpoint is our api route http://ourDomain.com/api/webhook/updateUser. Give it a description if you want and add the message event filters you want. In my case I only care about the user events. Then click create. This will create a webhook that sends data to our endpoint everytime something changes in clerks database. In our case it filters out only the events of the user user.created, user.deleted, user.updated.

clerk update user webhook

The part on the clerk side is done. Now we need to add the webhook to our backend. First we need to add the svix and micro library to our project.

npm install svix micro

Now let's write the handler.

//src/pages/api/webhooks/updateUser.ts
import { prisma } from '@/server/prisma'
import { buffer } from 'micro'

import { Webhook } from 'svix'

export const config = {
  api: {
    bodyParser: false,
  },
}
// clerk secret from the webhook above
const secret = process.env.CLERK_WEBHOOK_SECRET ?? ''

export default async function handler(req, res) {
  //collect the stream and parse it to a string
  const payload = (await buffer(req)).toString()
  const headers = req.headers
  // create a new webhook instance
  const wh = new Webhook(secret)
  let msg
  try {
    // verify the webhook and get the message
    msg = wh.verify(payload, headers)
  } catch (error) {
    return res.status(400).send(error.message)
  }

  const { id, username, profile_image_url: profileImageUrl } = msg.data

  const shouldIgnoreGravatar = profileImageUrl.includes('gravatar')
  // I don't want to use the gravatar image so I set it to null
  const sanitizedProfileImageUrl = shouldIgnoreGravatar ? null : profileImageUrl

  try {
    // delete case  (also deletes all the guestbook entries as it cascades)
    if (msg.data.deleted) {
      await prisma.user.delete({
        where: { id: msg.data.id },
      })
    }
    // update create case
    await prisma.user.upsert({
      where: { id: msg.data.id },
      update: {
        username,
        profileImageUrl: sanitizedProfileImageUrl,
      },
      create: {
        id,
        username,
        profileImageUrl: sanitizedProfileImageUrl,
      },
    })
    res.status(200).json()
  } catch (error) {
    console.error(error)
    res.status(error.requestResult.statusCode).send(error.message)
  }
}

Now everytime a user is created, updated or deleted in clerk it will also be updated in our database.

8. Update the JWT and session claims

Second thing is we will update the JWT token details to include not only the userId but in our case also the email, firstName, lastName and web3Wallets. These are called claims. You also can add such claims for other third party services like stripe or github if you want to interact with those. Just go to our clerk dashboard and click on the JWT Templates link. Add the details as in the image or read up on the stuff in the right bottom corner.

clerk custom jwt

Now the email, firstName,... are added to our JWT. And can be easily accessed via our useUser hook.

import { useUser } from '@clerk/nextjs'
export default function Index() {
  const { isLoaded, isSignedIn, user } = useUser()
  console.log('user', user)
  //todo manuel add the return of the user
}

Same for the backend. You need to update the session to include the claims and can access them with the getAuth function or via the context.

const ctx = {
  actor: undefined,
  sessionClaims: {
    azp: 'http://localhost:3000',
    email: null,
    exp: 1678276727,
    firstName: 'Clark',
    iat: 1678276667,
    iss: 'https://vast-ostrich-16.clerk.accounts.dev',
    jti: 'fe8915affaa83af9d10e',
    lastName: 'Kent',
    nbf: 1678276657,
    sid: 'sess_2MjB3LIcLMDC464laFoIkrRZiTu',
    sub: 'user_2Mj8pdXGjEPXYNNBXPI1gXDjxT3',
    userId: 'user_2Mj8pdXGjEPXYNNBXPI1gXDjxT3',
    web3Wallets: '{{user.web3Wallets}}',
  },
  sessionId: 'sess_2MjB3LIcLMDC464laFoIkrRZiTu',
  session: undefined,
  userId: 'user_2Mj8pdXGjEPXYNNBXPI1gXDjxT3',
  user: undefined,
  orgId: undefined,
  orgRole: undefined,
  orgSlug: undefined,
  organization: undefined,
  getToken: [AsyncFunction(anonymous)],
  debug: [Function(anosymous)],
}

9. Add the Social Connection Provider - Google

In order to go to production with our choosen social sign in method of google we need to add the google provider to our clerk dashboard. It's to get rid of this url in the sign in process.

social signin google

I am not going to go threw the steps as it depends on the social login solutions you want to use. Just got to Clerks documentation and follow the steps.

10. Yeahhh we did it! We did it! 🎉

If you have any questions feel free to contact me! I am happy to help.