← Back

How to setup Better-Auth with Nuxt 3 and Drizzle

This guide is specifically for when you have a Nuxt 3 + Drizzle Setup (I wrote a guide for this part too.) and now want to use Better-Auth with it. It is highly opinionated to what I use and what works for my projects. I use pnpm, but you can use every package manager you like of course.

I wrote this guide while migrating a project from Supabase Auth to Better Auth, so maybe this helps :) Let me know if something's unclear!

Let's start with the server/database side of the installation.

Install better-auth

pnpm add better-auth

Then, add these two new variables into your .env. You can generate a random string as a auth secret, or use the generator at the Better Auth Installation Guide.

BETTER_AUTH_SECRET="YOUR_RANDOM_SECRET"
BETTER_AUTH_URL="http://localhost:3000" # Base URL of your app

Add Server Auth Utils

Create new server/utils/auth.ts. This is the server-side better-auth client to work with. You also notice that this connects directly with your Drizzle Database instance.

import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { db } from './db'

export const auth = betterAuth({
	emailAndPassword: {
		enabled: true,
	},
	database: drizzleAdapter(db, {
		provider: 'pg',
	}),
})

Generate Auth DB schema

Now you can use the better-auth CLI to generate and migrate your new auth-related tables.

npx @better-auth/cli generate --config server/utils/auth.ts

It will ask you if you want to generate a schema, you can confirm that. it will generate a auth-schema.ts file in your project root. Move this file to server/db/auth-schema.ts.

In this file, there are a few things which I'd personally change.

1. Cuid2 IDs

I prefer to use automatically generated cuid2 ids. So for every table that's been generated, replace the id field with this:

// ...
import { createId } from '@paralleldrive/cuid2'

export const user = pgTable('user', {
	id: text('id').primaryKey().$defaultFn(createId),
	// ...
})

// ...

2. Timestamp with TZ

Instead of using the regular timestamps, I prefer to use timestamptz to also store the timezone. For every timestamp() usage, add the withTimezone: true option

// ...

export const user = pgTable('user', {
	// ...
	createdAt: timestamp('created_at', {
		withTimezone: true,
	}).notNull(),
	updatedAt: timestamp('updated_at', {
		withTimezone: true,
	}).notNull(),
})

// ...

Add schema to drizzle config

Now you should have 2 schema files in your /server/db directory. schema.ts and auth-schema.ts

For drizzle to notice this new schema, modify your drizzle.config.ts:

// ...
schema: ['./server/db/schema.ts', './server/db/auth-schema.ts'],
// ...

Also update your /server/utils/db.ts:

// ...
import * as schema from '../db/schema'
import * as authSchema from '../db/auth-schema'

// ...

export const db = drizzle(queryClient, {
	schema: {
		...schema,
		...authSchema,
	},
})

Migrate Auth DB Schema

After you have created your schema file, generate a migration file it with pnpm db:generate which is just an alias script for (drizzle-kit generate). The new sql migration file should create everything you need. You can migrate it depending on your migration strategy. I use pnpm db:migrate script which just runs drizzle-kit migrate.

Create Server API Catchall Endpoint

For the Nuxt API to be generated, create the following endpoint:

/server/api/[...auth].ts

import { auth } from '@/server/utils/auth'

export default defineEventHandler(event => {
	return auth.handler(toWebRequest(event))
})

That's all to make your whole Auth API work! Awesome!

Implement Client Composable

To have a global reusable composable to get the current user session, use something like this:

composables/auth.ts

import { createAuthClient } from 'better-auth/vue'

export const useAuthClient = () => {
	return createAuthClient({
		plugins: [],
	})
}

export const useAuthUser = async () => {
	const authClient = useAuthClient()

	return {
		user: (await authClient.useSession(useFetch)).data.value?.user,
	}
}

Implement Client-Side Middleware

To protect your routes, you can use a global middleware like this in middleware/auth.global.ts

export default defineNuxtRouteMiddleware(async to => {
	const isUserNavigatingToTheApp = to.path.startsWith('/app')
	if (!isUserNavigatingToTheApp) {
		return
	}

	const { data: loggedIn } = await useAuthClient().useSession(useFetch)
	if (!loggedIn.value) {
		return navigateTo('/auth/login')
	}
})

This makes sure that all routes that are /app or /app/... are protected and will be redirected to login if no authenticated user (session) has been found.

Implement Login

Again, super opinionated – This is just an example how you can implement a basic login screen with zod and Nuxt UI.

<template>
	<div>
		<h1>Login</h1>

		<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
			<UFormField label="Email" name="email">
				<UInput v-model="state.email" />
			</UFormField>

			<UFormField label="Password" name="password">
				<UInput v-model="state.password" type="password" />
			</UFormField>

			<UButton type="submit">Submit</UButton>
		</UForm>
	</div>
</template>

<script setup lang="ts">
	import * as z from 'zod'
	import type { FormSubmitEvent } from '#ui/types'

	const { signIn } = useAuthClient()

	const schema = z.object({
		email: z.string().email('Invalid email'),
		password: z.string(),
	})

	type Schema = z.output<typeof schema>

	const state = reactive<Partial<Schema>>({
		email: undefined,
		password: undefined,
	})

	const pending = ref(false)

	const toast = useToast()
	async function onSubmit(event: FormSubmitEvent<Schema>) {
		await signIn.email(
			{
				email: event.data.email,
				password: event.data.password,
			},
			{
				onRequest: () => {
					pending.value = true
				},
				onResponse: () => {
					pending.value = false
				},
				onSuccess: () => {
					navigateTo('/app')
				},
				onError: errorContext => {
					toast.add({
						title: 'Error',
						description: errorContext.error.message,
						color: 'red',
					})
				},
			}
		)
	}
</script>

Better Examples are in the Better Auth Docs for email authentication.

Disabling SSR

Highly opinionated too, but for everything auth & app related, I tend to just disable SSR in the nuxt.config.ts

export default defineNuxtConfig({
	routeRules: {
		'/app/*': {
			ssr: false,
		},
		'/auth/*': {
			ssr: false,
		},
	},
})

Going further

Congrats! 🚀 You now have a very simple Drizzle & Better Auth setup with Nuxt 3.


It is also incredibly easy to add social logins, passkeys or whatever, but that's kind of out of scope. The docs are really really well made – check them out.

You can also check out my example repo where I installed Nuxt UI, Drizzle and Better-Auth in an example: https://github.com/madebyfabian/nuxt-drizzle-better-auth