Important
This guide is based on the upcoming work in the alpha branch of TanStack Start. We are actively working on exciting new features, and this guide will be updated soon.
This guide provides a step-by-step process to integrate Better Auth with TanStack Start. We respect the powerful features of Better Auth and aim to make this implementation as smooth as possible.
This step-by-step guide provides an overview of how to integrate Better Auth with TanStack Start using a starter template. The goal is to help you understand the basic steps involved in the implementation process so you can adapt them to your specific project needs.
Before we begin, this guide assumes your project structure looks like this:
.
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
└── routes/
├── __root.tsx
├── globals.css
└── index.tsx
.
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
└── routes/
├── __root.tsx
├── globals.css
└── index.tsx
Alternatively, you can follow along by cloning the following starter template:
npx gitpick nrjdalal/awesome-templates/tree/main/tanstack-apps/tanstack-start better-start
npx gitpick nrjdalal/awesome-templates/tree/main/tanstack-apps/tanstack-start better-start
This structure or starter is a basic TanStack Start application, which we will integrate with TanStack Start.
npm i better-auth drizzle-orm postgres
npm i -D drizzle-kit
npm i better-auth drizzle-orm postgres
npm i -D drizzle-kit
# .env
BETTER_AUTH_URL=http://localhost:3000
# can be generated using `npx nanoid`
BETTER_AUTH_SECRET=
# can be generated using `npx pglaunch`
POSTGRES_URL=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# .env
BETTER_AUTH_URL=http://localhost:3000
# can be generated using `npx nanoid`
BETTER_AUTH_SECRET=
# can be generated using `npx pglaunch`
POSTGRES_URL=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
Updated project structure:
.
├── .env // [!code ++]
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
└── routes/
├── __root.tsx
├── globals.css
└── index.tsx
.
├── .env // [!code ++]
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
└── routes/
├── __root.tsx
├── globals.css
└── index.tsx
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
dbCredentials: {
url: process.env.POSTGRES_URL!,
},
schema: 'src/db/schema',
out: 'src/db/drizzle',
})
// drizzle.config.ts
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'postgresql',
dbCredentials: {
url: process.env.POSTGRES_URL!,
},
schema: 'src/db/schema',
out: 'src/db/drizzle',
})
// src/db/index.ts
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
declare global {
var db: PostgresJsDatabase
}
let db: PostgresJsDatabase
if (process.env.NODE_ENV === 'production') {
db = drizzle({
client: postgres(process.env.POSTGRES_URL!, {
ssl: {
rejectUnauthorized: true,
},
}),
})
} else {
if (!global.db) {
global.db = drizzle({
client: postgres(process.env.POSTGRES_URL!),
})
}
db = global.db
}
export { db }
// src/db/index.ts
import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
declare global {
var db: PostgresJsDatabase
}
let db: PostgresJsDatabase
if (process.env.NODE_ENV === 'production') {
db = drizzle({
client: postgres(process.env.POSTGRES_URL!, {
ssl: {
rejectUnauthorized: true,
},
}),
})
} else {
if (!global.db) {
global.db = drizzle({
client: postgres(process.env.POSTGRES_URL!),
})
}
db = global.db
}
export { db }
// src/db/schema/auth.ts
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified')
.$defaultFn(() => false)
.notNull(),
image: text('image'),
createdAt: timestamp('created_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp('updated_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
})
export const session = pgTable('session', {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
})
export const account = pgTable('account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
})
export const verification = pgTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').$defaultFn(
() => /* @__PURE__ */ new Date(),
),
updatedAt: timestamp('updated_at').$defaultFn(
() => /* @__PURE__ */ new Date(),
),
})
// src/db/schema/auth.ts
import { boolean, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified')
.$defaultFn(() => false)
.notNull(),
image: text('image'),
createdAt: timestamp('created_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
updatedAt: timestamp('updated_at')
.$defaultFn(() => /* @__PURE__ */ new Date())
.notNull(),
})
export const session = pgTable('session', {
id: text('id').primaryKey(),
expiresAt: timestamp('expires_at').notNull(),
token: text('token').notNull().unique(),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
})
export const account = pgTable('account', {
id: text('id').primaryKey(),
accountId: text('account_id').notNull(),
providerId: text('provider_id').notNull(),
userId: text('user_id')
.notNull()
.references(() => user.id, { onDelete: 'cascade' }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
idToken: text('id_token'),
accessTokenExpiresAt: timestamp('access_token_expires_at'),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
scope: text('scope'),
password: text('password'),
createdAt: timestamp('created_at').notNull(),
updatedAt: timestamp('updated_at').notNull(),
})
export const verification = pgTable('verification', {
id: text('id').primaryKey(),
identifier: text('identifier').notNull(),
value: text('value').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').$defaultFn(
() => /* @__PURE__ */ new Date(),
),
updatedAt: timestamp('updated_at').$defaultFn(
() => /* @__PURE__ */ new Date(),
),
})
Updated project structure:
.
├── .env
├── drizzle.config.ts // [!code ++]
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── db/
│ ├── index.ts // [!code ++]
│ └── schema/
│ └── auth.ts // [!code ++]
└── routes/
├── __root.tsx
├── globals.css
└── index.tsx
.
├── .env
├── drizzle.config.ts // [!code ++]
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── db/
│ ├── index.ts // [!code ++]
│ └── schema/
│ └── auth.ts // [!code ++]
└── routes/
├── __root.tsx
├── globals.css
└── index.tsx
// src/lib/auth/index.ts
import { db } from '@/db'
import { account, session, user, verification } from '@/db/schema/auth'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { reactStartCookies } from 'better-auth/react-start'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user,
session,
account,
verification,
},
}),
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [reactStartCookies()],
})
// src/lib/auth/index.ts
import { db } from '@/db'
import { account, session, user, verification } from '@/db/schema/auth'
import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { reactStartCookies } from 'better-auth/react-start'
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: 'pg',
schema: {
user,
session,
account,
verification,
},
}),
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
plugins: [reactStartCookies()],
})
// src/lib/auth/client.ts
import { createAuthClient } from 'better-auth/react'
export const { signIn, signOut, useSession } = createAuthClient()
// src/lib/auth/client.ts
import { createAuthClient } from 'better-auth/react'
export const { signIn, signOut, useSession } = createAuthClient()
// src/routes/api/auth/$.ts
import { auth } from '@/lib/auth'
export const ServerRoute = createServerFileRoute().methods({
GET: ({ request }) => {
return auth.handler(request)
},
POST: ({ request }) => {
return auth.handler(request)
},
})
// src/routes/api/auth/$.ts
import { auth } from '@/lib/auth'
export const ServerRoute = createServerFileRoute().methods({
GET: ({ request }) => {
return auth.handler(request)
},
POST: ({ request }) => {
return auth.handler(request)
},
})
// src/components/auth-button.tsx
import { signIn, signOut, useSession } from '@/lib/auth/client'
import { useLocation, useNavigate } from '@tanstack/react-router'
export default function Component() {
const navigate = useNavigate()
const { pathname } = useLocation()
const { data: session } = useSession()
if (session && pathname === '/') navigate({ to: '/dashboard' })
if (!session && pathname === '/dashboard') navigate({ to: '/' })
return session ? (
<>
<p>Welcome, {session.user.name}.</p>
<button
className="cursor-pointer rounded-full border px-4 py-1 text-gray-100 hover:opacity-80"
onClick={async () => {
await signOut(
{},
{
onSuccess: () => {
navigate({ to: '/' })
},
},
)
}}
>
Log Out
</button>
</>
) : (
<>
<p>Please log in to continue.</p>
<button
className="cursor-pointer rounded-full border px-4 py-1 text-gray-100 hover:opacity-80"
onClick={async () =>
await signIn.social({
provider: 'github',
callbackURL: '/dashboard',
})
}
>
Login with Github
</button>
</>
)
}
// src/components/auth-button.tsx
import { signIn, signOut, useSession } from '@/lib/auth/client'
import { useLocation, useNavigate } from '@tanstack/react-router'
export default function Component() {
const navigate = useNavigate()
const { pathname } = useLocation()
const { data: session } = useSession()
if (session && pathname === '/') navigate({ to: '/dashboard' })
if (!session && pathname === '/dashboard') navigate({ to: '/' })
return session ? (
<>
<p>Welcome, {session.user.name}.</p>
<button
className="cursor-pointer rounded-full border px-4 py-1 text-gray-100 hover:opacity-80"
onClick={async () => {
await signOut(
{},
{
onSuccess: () => {
navigate({ to: '/' })
},
},
)
}}
>
Log Out
</button>
</>
) : (
<>
<p>Please log in to continue.</p>
<button
className="cursor-pointer rounded-full border px-4 py-1 text-gray-100 hover:opacity-80"
onClick={async () =>
await signIn.social({
provider: 'github',
callbackURL: '/dashboard',
})
}
>
Login with Github
</button>
</>
)
}
Updated project structure:
.
├── .env
├── drizzle.config.ts
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── components/
│ └── auth-button.tsx // [!code ++]
├── db/
│ ├── index.ts
│ └── schema/
│ └── auth.ts
├── lib/
│ └── auth/
│ ├── client.ts // [!code ++]
│ └── index.ts // [!code ++]
└── routes/
├── __root.tsx
├── globals.css
├── index.tsx
└── api/
└── auth/
└── $.ts // [!code ++]
.
├── .env
├── drizzle.config.ts
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── components/
│ └── auth-button.tsx // [!code ++]
├── db/
│ ├── index.ts
│ └── schema/
│ └── auth.ts
├── lib/
│ └── auth/
│ ├── client.ts // [!code ++]
│ └── index.ts // [!code ++]
└── routes/
├── __root.tsx
├── globals.css
├── index.tsx
└── api/
└── auth/
└── $.ts // [!code ++]
npx drizzle-kit push
# [✓] Pulling schema from database...
# [✓] Changes applied
npx drizzle-kit push
# [✓] Pulling schema from database...
# [✓] Changes applied
// src/routes/index.tsx
import AuthButton from '@/components/auth-button' // [!code ++]
export const Route = createFileRoute({
component: Component,
})
function Component() {
return (
<main className="bg-radial flex min-h-dvh flex-col items-center justify-center gap-y-4 from-cyan-950 to-black p-4 text-gray-100">
<img
className="aspect-square w-full max-w-sm"
src="https://tanstack.com/assets/splash-dark-8nwlc0Nt.png"
alt="TanStack Logo"
/>
<h1 className="text-2xl">
<span className="font-semibold">TanStack</span>
<span className="text-cyan-500">Start</span>
</h1>
<AuthButton /> // [!code ++]
<a
className="rounded-full bg-gray-100 px-4 py-1 text-gray-900 hover:opacity-80"
href="https://tanstack.com/start/latest"
target="_blank"
>
Docs
</a>
</main>
)
}
// src/routes/index.tsx
import AuthButton from '@/components/auth-button' // [!code ++]
export const Route = createFileRoute({
component: Component,
})
function Component() {
return (
<main className="bg-radial flex min-h-dvh flex-col items-center justify-center gap-y-4 from-cyan-950 to-black p-4 text-gray-100">
<img
className="aspect-square w-full max-w-sm"
src="https://tanstack.com/assets/splash-dark-8nwlc0Nt.png"
alt="TanStack Logo"
/>
<h1 className="text-2xl">
<span className="font-semibold">TanStack</span>
<span className="text-cyan-500">Start</span>
</h1>
<AuthButton /> // [!code ++]
<a
className="rounded-full bg-gray-100 px-4 py-1 text-gray-900 hover:opacity-80"
href="https://tanstack.com/start/latest"
target="_blank"
>
Docs
</a>
</main>
)
}
Just copy src/routes/index.tsx to src/routes/dashboard.tsx.
// src/routes/dashboard.tsx
import AuthButton from '@/components/auth-button'
export const Route = createFileRoute({
component: Component,
})
function Component() {
return (
<main className="bg-radial flex min-h-dvh flex-col items-center justify-center gap-y-4 from-cyan-950 to-black p-4 text-gray-100">
<img
className="aspect-square w-full max-w-sm"
src="https://tanstack.com/assets/splash-dark-8nwlc0Nt.png"
alt="TanStack Logo"
/>
<h1 className="text-2xl">
<span className="font-semibold">TanStack</span>
<span className="text-cyan-500">Start</span>
</h1>
<AuthButton />
<a
className="rounded-full bg-gray-100 px-4 py-1 text-gray-900 hover:opacity-80"
href="https://tanstack.com/start/latest"
target="_blank"
>
Docs
</a>
</main>
)
}
// src/routes/dashboard.tsx
import AuthButton from '@/components/auth-button'
export const Route = createFileRoute({
component: Component,
})
function Component() {
return (
<main className="bg-radial flex min-h-dvh flex-col items-center justify-center gap-y-4 from-cyan-950 to-black p-4 text-gray-100">
<img
className="aspect-square w-full max-w-sm"
src="https://tanstack.com/assets/splash-dark-8nwlc0Nt.png"
alt="TanStack Logo"
/>
<h1 className="text-2xl">
<span className="font-semibold">TanStack</span>
<span className="text-cyan-500">Start</span>
</h1>
<AuthButton />
<a
className="rounded-full bg-gray-100 px-4 py-1 text-gray-900 hover:opacity-80"
href="https://tanstack.com/start/latest"
target="_blank"
>
Docs
</a>
</main>
)
}
Updated project structure:
.
├── .env
├── drizzle.config.ts
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── components/
│ └── auth-button.tsx
├── db/
│ ├── index.ts
│ └── schema/
│ └── auth.ts
├── lib/
│ └── auth/
│ ├── client.ts
│ └── index.ts
└── routes/
├── __root.tsx
├── dashboard.tsx // [!code ++]
├── globals.css
├── index.tsx
└── api/
└── auth/
└── $.ts
.
├── .env
├── drizzle.config.ts
├── package.json
├── README.md
├── tsconfig.json
├── vite.config.ts
└── src/
├── router.tsx
├── components/
│ └── auth-button.tsx
├── db/
│ ├── index.ts
│ └── schema/
│ └── auth.ts
├── lib/
│ └── auth/
│ ├── client.ts
│ └── index.ts
└── routes/
├── __root.tsx
├── dashboard.tsx // [!code ++]
├── globals.css
├── index.tsx
└── api/
└── auth/
└── $.ts
Run the development server:
npm run dev
npm run dev
Visit http://localhost:3000 in your browser. You should see the TanStack Start splash page with a "Login with Github" button. Clicking this button will redirect you to the Github login page, and upon successful login, you will be redirected back to the dashboard.
You can also visit http://localhost:3000/dashboard directly, but you will be redirected to the home page if you are not logged in and vice versa.
If you encounter issues, review the steps above and ensure that file names and paths match exactly. For a reference implementation, see the post-migration repository or website demo.
Your weekly dose of JavaScript news. Delivered every Monday to over 100,000 devs, for free.