Add Google authentication to SvelteKit with Better Auth
By Justin Ahinon.
Last updated
Update (April 2026): This post originally used Lucia. Lucia was deprecated in 2024 and is now a learning resource, not a library. The version below uses Better Auth, which handles the same Google OAuth flow with a fraction of the code.
Adding Google authentication to a SvelteKit app is one of the fastest ways to cut sign-up friction. We'll do it with Better Auth, Postgres, and Drizzle. No hand-rolled OAuth code verifiers, no token-exchange endpoint to debug.
How OAuth works
OAuth lets a user authorise your app to use their Google identity without handing you their password. The browser redirects to Google, the user consents to the scopes you asked for, Google redirects back with a short-lived authorisation code, and your server exchanges that code for tokens it uses to fetch the user's profile.
Better Auth handles every step of that exchange for you. The flow is still worth knowing when you're debugging a redirect_uri_mismatch, but you won't write the plumbing yourself.
Why Better Auth and not Lucia
Lucia's author pitches the project as a guide for rolling your own session code now, not a package you install. You can still follow that playbook with Arctic for the OAuth primitives, but the amount of code you end up owning is significant.
Better Auth gives you a typed auth client, a Drizzle adapter, session management, and a generated database schema. It's the closest drop-in replacement for Lucia in the SvelteKit ecosystem.
Setting up the SvelteKit project
Scaffold a new SvelteKit app with the Node adapter. Better Auth runs on any adapter, but Node is the simplest starting point.
bunx sv create sveltekit-google-better-auth
cd sveltekit-google-better-auth
bun install
Setting up the database
Better Auth needs somewhere to store users, sessions, accounts, and verification tokens. We'll use Postgres with Drizzle ORM. The postgres.js driver keeps the dependency footprint small.
bun add drizzle-orm postgres
bun add -D drizzle-kit
Add a DATABASE_URL pointing at a local Postgres database:
echo "DATABASE_URL=postgresql://localhost:5432/sveltekit_better_auth" >> .env
Then create the Drizzle client:
// src/lib/server/db/client.ts
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { DATABASE_URL } from '$env/static/private';
if (!DATABASE_URL) throw new Error('DATABASE_URL is not set');
const client = postgres(DATABASE_URL);
const db = drizzle(client);
export { db };
And a minimal drizzle-kit config at the project root. Drizzle runs outside SvelteKit, so it reads process.env directly:
// drizzle.config.ts
import 'dotenv/config';
import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
dbCredentials: { url: process.env.DATABASE_URL },
dialect: 'postgresql',
verbose: true,
strict: true
});
Add migration scripts to package.json so you can generate and apply schema changes later:
{
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
}
}
The schema file itself doesn't exist yet. Better Auth's CLI will generate it for us in a few steps.
Google Cloud project
Google needs to know which app is asking for OAuth access. Create a new project in the Google Cloud Console. The name only matters to you.
The OAuth consent screen
This is the page Google shows to your users when they sign in. Go to APIs & Services → OAuth consent screen, pick External, and add these scopes:
openidhttps://www.googleapis.com/auth/userinfo.emailhttps://www.googleapis.com/auth/userinfo.profile
While the app is unverified, Google only lets you log in with the email on the project and with test users you explicitly list. Add any extra email you want to sign in with under Test users.
OAuth client ID
Under Credentials → Create credentials → OAuth client ID, pick Web application.
Set Authorized JavaScript origins to http://localhost:5173 (and your production URL when you deploy).
Set Authorized redirect URIs to http://localhost:5173/api/auth/callback/google. Better Auth mounts its callback at /api/auth/callback/{provider}. This path is fixed. Getting it wrong is the most common cause of redirect_uri_mismatch errors on sign-in.
Copy the client ID and secret into .env:
echo "GOOGLE_CLIENT_ID=your-client-id" >> .env
echo "GOOGLE_CLIENT_SECRET=your-client-secret" >> .env
echo "BETTER_AUTH_URL=http://localhost:5173" >> .env
Setting up Better Auth
Install the package:
bun add better-auth
Generate a signing secret and add it to .env:
echo "BETTER_AUTH_SECRET=$(bunx @better-auth/cli secret)" >> .env
Create the server config. This is where Better Auth wires together the Drizzle adapter, the Google provider, and the SvelteKit cookie helpers:
// src/lib/server/auth.ts
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import {
BETTER_AUTH_SECRET,
BETTER_AUTH_URL,
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET
} from '$env/static/private';
import { db } from '$lib/server/db/client';
const auth = betterAuth({
baseURL: BETTER_AUTH_URL,
secret: BETTER_AUTH_SECRET,
database: drizzleAdapter(db, { provider: 'pg' }),
socialProviders: {
google: {
clientId: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET
}
},
plugins: [sveltekitCookies(getRequestEvent)]
});
export { auth };
Two details worth calling out. sveltekitCookies must be the last plugin in the array. It depends on everything before it to set cookies correctly from server actions. And provider in the Drizzle adapter is pg for Postgres, not postgresql.
Then the client, which the Svelte components will use to kick off sign-in and sign-out:
// src/lib/auth-client.ts
import { createAuthClient } from 'better-auth/svelte';
const authClient = createAuthClient();
export { authClient };
Generating the database schema
Better Auth ships a CLI that inspects your config and emits the Drizzle schema for the tables it needs (user, session, account, verification):
bunx @better-auth/cli generate --output src/lib/server/db/schema.ts
Then generate and apply the SQL migration. Better Auth's own migrate command only works with the built-in Kysely adapter. For Drizzle, go through drizzle-kit:
bun run db:generate
bun run db:migrate
Hooks
On every request, we want to resolve the current session and make it available as event.locals. Better Auth ships svelteKitHandler to handle the /api/auth/* routes (including the Google callback), but it doesn't populate locals for you, so you resolve the session yourself:
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { building } from '$app/environment';
import { svelteKitHandler } from 'better-auth/svelte-kit';
import { auth } from '$lib/server/auth';
export const handle: Handle = async ({ event, resolve }) => {
const session = await auth.api.getSession({ headers: event.request.headers });
event.locals.session = session?.session ?? null;
event.locals.user = session?.user ?? null;
return svelteKitHandler({ event, resolve, auth, building });
};
Add the types in src/app.d.ts so TypeScript knows what's on locals:
// src/app.d.ts
import type { auth } from '$lib/server/auth';
declare global {
namespace App {
interface Locals {
user: typeof auth.$Infer.Session.user | null;
session: typeof auth.$Infer.Session.session | null;
}
}
}
export {};
The login page
The entire sign-in flow is one function call. authClient.signIn.social redirects the browser to Google, and once the callback returns, Better Auth creates the user and session and redirects to callbackURL.
<!-- src/routes/login/+page.svelte -->
<script lang="ts">
import { authClient } from '$lib/auth-client';
const signInWithGoogle = async () => {
await authClient.signIn.social({
provider: 'google',
callbackURL: '/profile'
});
};
</script>
<svelte:head>
<title>Login with Google</title>
</svelte:head>
<div class="max-w-5xl mx-auto px-4 flex items-center justify-center h-screen">
<div class="sm:w-6/12 flex p-6 border border-gray-300 shadow-md rounded-md flex-col gap-4 items-center">
<h1 class="text-2xl text-gray-700 font-bold">Login with Google</h1>
<button
onclick={signInWithGoogle}
class="bg-gray-800 text-white font-bold rounded-md text-sm hover:bg-gray-700 px-4 py-2"
>
Continue with Google
</button>
</div>
</div>
The profile route
Guard the page on the server by checking locals.user. The hook already resolved it for us.
// src/routes/profile/+page.server.ts
import { redirect } from '@sveltejs/kit';
export const load = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
return { user: locals.user };
};
Then render the user, and wire up sign-out from the same component:
<!-- src/routes/profile/+page.svelte -->
<script lang="ts">
import { authClient } from '$lib/auth-client';
import { goto } from '$app/navigation';
let { data } = $props();
const signOut = async () => {
await authClient.signOut();
goto('/login');
};
</script>
<svelte:head>
<title>{data.user.name}'s profile</title>
</svelte:head>
<div class="max-w-5xl mx-auto px-4 py-12 flex flex-col gap-4">
<h1 class="text-2xl font-bold">Hello {data.user.name}</h1>
<p>You are logged in as {data.user.email}.</p>
{#if data.user.image}
<img src={data.user.image} alt="{data.user.name}'s profile picture" class="w-20 h-20 rounded-full" />
{/if}
<button onclick={signOut} class="self-start bg-gray-800 text-white rounded-md px-4 py-2">
Sign out
</button>
</div>
Start the dev server, hit /login, and you should bounce through Google and land on /profile with your name, email, and avatar.
Wrapping up
Better Auth takes a feature that used to require a couple hundred lines of code (state cookies, PKCE verifiers, token exchange, userinfo fetches, session cookies) and reduces it to a config file and a button. The tradeoff is that you're accepting one library's opinions about schema and session management. For most SvelteKit apps, that's a very good trade.
If you want to offer email and password alongside Google, the next step is SvelteKit email and password authentication. Same library, same database tables, a few more fields on the sign-up form.
Happy coding!