# How to implement authentication with React and Convex

# Introduction

In my last side project, I built a [note taking app inspired by Apple Notes](https://github.com/tigerabrodi/jinwooo).

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1737575005094/39891941-0aa3-48ee-ac7c-98737e3057bf.png align="center")

I used Convex for my backend. For authentication, I used [Convex Auth](https://labs.convex.dev/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](https://reactrouter.com/start/library/routing) and [React 19](https://react.dev/blog/2024/12/05/react-19).

In [App.tsx](https://github.com/tigerabrodi/jinwooo/blob/main/src/App.tsx), you'll see:

```javascript
<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](https://reactrouter.com/start/library/routing#nested-routes).

Let's take a look at the [entry page](https://github.com/tigerabrodi/jinwooo/blob/main/src/pages/auth-entry/index.tsx):

```javascript
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](https://github.com/tigerabrodi/jinwooo/blob/main/src/pages/auth-entry/components/register-form.tsx).

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.

```javascript
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:

```javascript

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:

```javascript
    <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](https://github.com/tigerabrodi/jinwooo/blob/main/src/pages/auth-entry/components/login-form.tsx) is easier. Because if errors happen, to not hint anything to malicious users, we return the same generic error message.

```javascript
  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](https://github.com/tigerabrodi/jinwooo/blob/main/src/pages/notebook/root-page.tsx).

```javascript
  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](https://github.com/tigerabrodi/jinwooo/blob/main/convex/auth.ts).

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

```javascript
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!
