How to implement authentication with React and Convex

Tiger AbrodiTiger Abrodi
7 min read

Introduction

In my last side project, I built a note taking app inspired by Apple Notes.

I used Convex for my backend. For authentication, I used Convex Auth.

I recommend going through the documentation of Convex Auth and reading this post along the way.

What is Convex Auth?

Convex Auth is built on top of Auth.js (previously known as NextAuth.js). They follow the same principles where you can use multiple providers.

In my side project, I specifically used the Password provider for email/password authentication.

In their docs, you'll find multiple ways to implement authentication.

React part

Let's go over my React code. I'm using React Router v7 as a library and React 19.

In App.tsx, you'll see:

<Routes>
  <Route path="/" element={<AuthEntryPage />} />

  <Route path="notebook" element={<RootPage />}>
    <Route index element={<SelectFolderPage />} />
    <Route path=":folderId" element={<FolderPage />}>
      <Route index element={<SelectNotePage />} />
      <Route path=":noteId" element={<NotePage />} />
    </Route>
  </Route>
</Routes>

Entry page

We have the / route which is the entry point of the app for unauthenticated users.

For authenticated users, they'll be redirected to the /notebook route. The nesting you see here is to achieve nested routes.

Let's take a look at the entry page:

const user = useQuery(api.users.getCurrentUser);
const state = useConvexAuth();
// Are we still getting the user?
// Or is the authentication state loading?
const isLoading = user === undefined || state.isLoading;
const navigate = useNavigate();

useEffect(() => {
  // If user is authenticated
  // Redirect to the initial folder
  if (!isLoading && user && user.initialFolderId) {
    void navigate(
      generatePath(ROUTES.notebookFolder, { folderId: user.initialFolderId })
    );
  }
}, [isLoading, user, navigate]);

getCurrentUser is a query that returns the current user. useConvexAuth is a hook that returns the authentication state.

In Convex, in general, undefined means the query isn't done yet. It's loading. null means the query is done and there's no data.

We know we're authenticated when user is not undefined and state.isAuthenticated is true.

For every user, I create an initial folder. I'll show later how that's done.

To keep things type safe, I use generatePath from react router to create the path. We need void on navigate since it can also return a promise.

The auth entry page is for unauthenticated users. For the ones logged in, they should be redirected to the initial folder.

Register Form

Code to register form.

In Convex Auth, you need a hidden input which convex uses to determine whether you're signing up or logging in. You'll use the same signIn function for both. But Convex knows via the hidden input whether you're signing up or logging in.

const SIGN_UP_STEP = 'signUp'

<input name="flow" type="hidden" value={SIGN_UP_STEP} />

I'm using useActionState from React 19 for the form submission:


type FormState =
  | {
      status: 'error'
      errors: {
        email: string
        password: string
      }
    }
  | {
      status: 'success'
    }

  const [state, formAction, isPending] = useActionState<FormState, FormData>(
    async (_, formData) => {
      const email = formData.get('email') as string
      const password = formData.get('password') as string
      const confirmPassword = formData.get('confirmPassword') as string

      const errors = {
        email: '',
        password: '',
      }

      if (password.length < 6) {
        errors.password = 'Password must be at least 6 characters long'
        return { status: 'error', errors }
      }

      if (password !== confirmPassword) {
        errors.password = 'Passwords do not match'
        return { status: 'error', errors }
      }

      const [existingUser, existingUserError] = await handlePromise(
        convex.query(api.users.getUserByEmail, { email })
      )

      if (existingUserError) {
        errors.email =
          'Something went wrong during registration. Please try later.'
        return { status: 'error', errors }
      }

      if (existingUser) {
        errors.email = 'Email already exists'
        return { status: 'error', errors }
      }

      const [, signInError] = await handlePromise(signIn('password', formData))

      if (signInError) {
        errors.email =
          'Something went wrong during registration. Please try later.'
        return { status: 'error', errors }
      }

      return { status: 'success' }
    },
    { status: 'error', errors: { email: '', password: '' } }
  )

  useEffect(() => {
    if (state.status === 'success') {
      toast({
        title: 'Registration successful',
        description: 'You can now start taking notes.',
      })
    }
  }, [state.status, toast])

You can decide how you wanna do it. This is how I did it and it works nicely.

We use the "state" returned from useActionState to determine whether the form submission was successful or not. As you can see, I'm using a discriminated union to make sure it's type safe.

If status is error, we know we have the other error fields on the state.

If status is successful, we can react with useEffect to show a toast.

Wait, the redirect isn't happening here?

It's happening the entry page I showed you earlier. In Convex, useQuery is real-time. So as soon as the user is created, the query will return the user.

I didn't show it before, but this is the UI of the entry page:

    <div className="flex flex-1 items-center justify-center p-4">
      <Card className="w-full max-w-md">
        <CardHeader className="flex flex-col gap-1">
          <CardTitle className="font-ninja text-center text-2xl text-primary">
            Jinwoo
          </CardTitle>
        </CardHeader>
        <CardContent>
          <Tabs
            value={tab}
            onValueChange={(value) =>
              setTab(value as (typeof TAB_VALUES)[keyof typeof TAB_VALUES])
            }
            className="w-full"
          >
            <TabsList className="grid w-full grid-cols-2">
              <TabsTrigger value={TAB_VALUES.LOGIN}>Login</TabsTrigger>
              <TabsTrigger value={TAB_VALUES.REGISTER}>Register</TabsTrigger>
            </TabsList>

            <TabsContent value={TAB_VALUES.LOGIN} className="pt-4">
              <LoginForm />
            </TabsContent>

            <TabsContent value={TAB_VALUES.REGISTER} className="pt-4">
              <RegisterForm />
            </TabsContent>
          </Tabs>
        </CardContent>
      </Card>
    </div>

Login Form

Login form is easier. Because if errors happen, to not hint anything to malicious users, we return the same generic error message.

  const [state, formAction, isPending] = useActionState<FormState, FormData>(
    async (_, formData) => {
      const errors = {
        email: '',
      }

      const [, signInError] = await handlePromise(signIn('password', formData))

      if (signInError) {
        errors.email = 'Something went wrong.'
        return { status: 'error', errors }
      }

      return { status: 'success' }
    },
    { status: 'error', errors: { email: '' } }
  )

  useEffect(() => {
    if (state.status === 'success') {
      toast({
        title: 'Login successful',
        description: 'Welcome back!',
      })
    }
  }, [state.status, toast])

The key part here is that the hidden input is set to signIn instead of signUp.

Notebook (logged in users)

This page is only for authenticated users. This is the root page.

  const user = useQuery(api.users.getCurrentUser)
  const state = useConvexAuth()
  const isLoading = user === undefined || state.isLoading
  const navigate = useNavigate()
  const { folderId } = useParams<{ folderId: string }>()

  useEffect(() => {
    if (!isLoading && user === null) {
      void navigate(ROUTES.authEntry)
    }
  }, [isLoading, user, navigate])

  useEffect(() => {
    // Is the user trying to navigate to `/notebook`?
    // redirect to their initial folder
    if (!isLoading && user && !folderId) {
      void navigate(ROUTES.authEntry)
    }
  }, [folderId, isLoading, navigate, user])

We do the same thing as in the entry page. This time however, we check if user is null. It means query is done but there's no user. Therefore, the user is unauthenticated and should be redirected to the entry page. And of course, we also check that isLoading is done.

The second useEffect is to check if the user is trying to navigate to the root page. If they are, we redirect them to the initial folder. Why? Because there is not point in showing only the sidebar with folders and not being inside a specific folder.

You can handle this edge case however you want. That's how I decided to do it.

Convex Code

If you follow the docs, everything is pretty straightforward.

One thing that may not be too straightforward is creating additional data upon registration. Or if you yourself wanna take full ownership of the user creation.

That's what I'm doing in this file: auth.ts.

It's also not type safe, which is why I'm annotating the types here:

import { Password } from '@convex-dev/auth/providers/Password'
import { convexAuth } from '@convex-dev/auth/server'
import { DataModel } from './_generated/dataModel'
import { MutationCtx } from './_generated/server'

export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
  providers: [Password<DataModel>()],
  callbacks: {
    // `MutationCtx` annotated to keep type safety
    async createOrUpdateUser(ctx: MutationCtx, args) {
      // If user already exists, return the existing user
      if (args.existingUserId) {
        return args.existingUserId
      }

      // First create the user
      const userId = await ctx.db.insert('users', {
        email: args.profile.email || '',
        updatedAt: Date.now(),
        initialFolderId: null,
      })

      // Then create the initial folder directly with ctx.db
      const initialFolderId = await ctx.db.insert('folders', {
        name: 'All Notes',
        depth: 0,
        isInitial: true,
        // Count starting at 1 because we're creating the initial note right after
        noteCount: 1,
        parentId: null,
        userId: userId, // We can use the userId directly
        updatedAt: Date.now(),
      })

      // Create welcome note directly
      await ctx.db.insert('notes', {
        title: 'Welcome to Jinwoo!',
        content: 'Jinwoo is a fun lil note taking app.',
        preview: 'Jinwoo is a fun lil note taking app.',
        folderId: initialFolderId,
        userId: userId,
        updatedAt: Date.now(),
      })

      // Update user with the folder
      await ctx.db.patch(userId, {
        initialFolderId: initialFolderId,
      })

      // New user's id
      return userId
    },
  },
})

A gotcha to be aware of

In the callbacks, you can specify createOrUpdateUser or afterUserCreatedOrUpdated.

createOrUpdateUser is called when the user is created or updated.

afterUserCreatedOrUpdated is called AFTER the user is created or updated. Which means leaving creation to the default behavior.

From the docs:

This callback is only called if createOrUpdateUser is not specified. If createOrUpdateUser is specified, you can perform any additional writes in that callback.

If you specify createOrUpdateUser, the afterUserCreatedOrUpdated callback is not called!

0
Subscribe to my newsletter

Read articles from Tiger Abrodi directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tiger Abrodi
Tiger Abrodi

Just a guy who loves to write code and watch anime.