The simplest way to add Google sign-in to your Next.js app ✍️

Published: May 21, 2025

This guide shows how to spin up a Next.js (App Router) project that signs users in with Google.

We'll use Prisma ORM and BetterAuth.

The final product is in this Github repo that you can clone and start using as a starter project:

tomdekan/tom-auth-starter
View on GitHub

Here's a demo of what we'll build:

Self-hosted auth vs managed auth

When building OAuth, you have a choice of either:

  1. managing auth yourself in your existing database, or
  2. using an additional managed service for auth and storing your user table, like Clerk or Auth0.

I've used a mix of both in the past. I prefer keeping all my data in one database and handling authentication myself. Some benefits are:

  • complete control over your data
  • a faster development experience because you don't need to set up a webhook or a tunnel (Ngrok or Tailscale) in order to test Sign-In locally. You would need these tools if you were using an external managed provider to call your local computer when you're developing.

This guide is for the first option.

1. Scaffold the project

npx create-next-app@latest ultra-starter-app \
--typescript --app --eslint --tailwind --src-dir --import-alias "@/*"
cd ultra-starter-app

The flags give you TypeScript, the new app/ router, Tailwind, and clean import paths — nothing else.


2. Install runtime + tooling

You can store user sessions in your own database, or a managed user database like Clerk or Auth0.

I much prefer to store user data in my own database; it's secure, flexible, and cheaper than using a managed service. One key advantage is that you don't need to always expose an internet endpoint from your local machine when developing for the external managed service.

So, I'll use a PostgreSQL database to store user sessions.

Install the dependencies:

pnpm add better-auth @prisma/client
pnpm add -D prisma @better-auth/cli

BetterAuth is the auth engine we'll use. The CLI will later generate the Prisma schema for you. You can write this schema manually, but I'll use the CLI to generate it.

3. Environment variables

Create .env in the repo root (E.g., inside ./ultra-starter-app):

# BetterAuth
BETTER_AUTH_SECRET=replace-with-64-random-hex
BETTER_AUTH_URL=http://localhost:3000
# Google OAuth
GOOGLE_CLIENT_ID=xxx.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=yyy
# Database
DATABASE_URL=postgresql://<user-to-create>:<password-to-create>@localhost:5432/<database-to-create>

To set the BETTER_AUTH_SECRET, you can use openssl rand -hex 32 to generate a random secret. Leaving any of these blank will crash the server – by design, not a placeholder.

How to set up your PostgreSQL database

  1. Install PostgreSQL if you don't have it already (E.g., using Homebrew):
brew install postgresql

Alternatively, you can install it using Postgres.app, which offers a nice graphical interface.

  1. Create a user with a password and database:
createuser -s ultra_starter_user
psql -c "ALTER USER ultra_starter_user WITH PASSWORD 'special-password';"
createdb ultra_starter_db
  1. Update your .env file with the credentials:
DATABASE_URL=postgresql://ultra_starter_user:special-password@localhost:5432/ultra_starter_db

Get Google client ID and secret

These are the details that tie your app to Google. This is tricky to find, so I'll walk you through it.

As a prerequisite, you need a Google account.

  1. Go to the Google Cloud Console.
  2. Create a new project. (You might need to click on your existing project to see this option.)
  3. Select the new project
  4. Go to the OAuth consent screen and complete the form
  5. Click to create Oauth client ID and secret.
  6. Select web application.
  7. Enter the following:
    • Authorized JavaScript origins: http://localhost:3000
    • Authorized redirect URIs: http://localhost:3000/api/auth/callback/google (Note: We'll need to add the production URL here after we deploy to Vercel.)
  8. Click create and download the JSON credentials file.
  9. Open the JSON file
  10. Copy the client ID and secret and paste them into your .env file.

4. Initialize Prisma

  • From the root of the project, run:
npx prisma init --datasource-provider postgresql

The command creates a bare-bones prisma/schema.prisma. As mentioned earlier, you can write this schema manually, but I'll use the CLI to generate it.

  • Generate your prisma client: This is the generated typescript code that prisma uses to interact with your database:
  • Generate your prisma client:
pnpm prisma generate

Connect BetterAuth to your database

We need to create a Better Auth instance.

  • Create a file named auth.ts in your project root
touch auth.ts
  • Import Better Auth and create your auth instance. Make sure to export the auth instance with the variable name auth or as a default export.
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "./src/generated/prisma";
const prisma = new PrismaClient();
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql"
})
})

5. Connect Prisma to BetterAuth

  • Generate the schema fragments BetterAuth needs:
npx @better-auth/cli@latest generate --y

The CLI merges the extra models into prisma/schema.prisma; no manual edits needed.

you can check the generated schema in the prisma/schema.prisma file. You should see User, Session, Account, and Verification tables.

Trouble shooting: If this fails with Couldn't read your auth config., follow the steps in the error message and make sure that your imported Prisma client path is pointing to the correct location of the generatd prima client.

  • Make sure

  • Run an initial migration:

npx prisma migrate dev --name auth

You should see the following output: Prisma migration


6. Update your auth.ts file

In ./auth.ts:

import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { PrismaClient } from "./src/generated/prisma";
import { inferAdditionalFields } from "better-auth/client/plugins";
const prisma = new PrismaClient();
export const auth = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql"
}),
socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
},
},
plugins: [inferAdditionalFields()],
})
export type Session = typeof auth.$Infer.Session

7. Expose the auth handler to Next.js (App Router)

In ./app/api/auth/[...all]/route.ts (Create this file if it doesn't exist):

import { auth } from '../../../../auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth.handler)

8. Create a thin client wrapper

In ./src/lib/auth-client.ts (Create this file if it doesn't exist)b:

import { inferAdditionalFields } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import type { auth } from '../../auth'
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()]
})
export type Session = typeof authClient.$Infer.Session

9. Create a public sign-in page

In ./app/(auth)/sign-in/page.tsx (Create this file if it doesn't exist):

'use client'
import { authClient } from '@/lib/auth-client'
export default function SignIn() {
const handleLogin = async () =>
authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })
return (
<main className="flex min-h-screen items-center justify-center">
<button
onClick={handleLogin}
className="rounded-lg bg-black px-6 py-3 text-white hover:opacity-80"
>
Sign in with Google
</button>
</main>
)
}

10. Create an auth client

This is the code you'll use to interact with the auth service.

Create ./src/lib/auth-client.ts and add the following code:

import { inferAdditionalFields } from 'better-auth/client/plugins'
import { createAuthClient } from 'better-auth/react'
import type { auth } from '../../auth'
export const authClient = createAuthClient({
plugins: [inferAdditionalFields<typeof auth>()]
})
export type Session = typeof authClient.$Infer.Session

10. Create a sign out button

  • Create a sign out button component in ./components/SignOutButton.tsx (Create this file if it doesn't exist):
'use client'
import { authClient } from '@/lib/auth-client'
import { useRouter } from 'next/navigation'
export const SignOutButton = () => {
const router = useRouter()
const handleSignOut = async () => {
await authClient.signOut()
router.refresh()
}
return (
<button
onClick={handleSignOut}
className="text-xs text-red-300 hover:text-red-200 transition"
>
Sign Out
</button>
)
}

11. Create a protected dashboard page

This is the page that the user won't be able to access without being signed in.

In ./app/dashboard/page.tsx (Create this file):

import { redirect } from 'next/navigation'
import { SignOutButton } from '../../components/SignOutButton'
import { authClient } from '@/lib/auth-client'
export default async function Dashboard() {
const sessionResponse = await authClient.getSession()
const session = sessionResponse.data
if (!session) {
redirect('/sign-in')
}
return (
<section className="p-10">
<h1 className="text-2xl font-bold">Welcome, {session.user.name}!</h1>
<p className="mt-2">You made it to the protected area. 🎉</p>
<SignOutButton />
</section>
)
}

11. Lock it down globally with middleware

In ./middleware.ts (Create this file if it doesn't exist):

This is the middleware that will check if the user is signed in. If they are not, it will redirect them to the sign-in page.

import { NextRequest, NextResponse } from 'next/server'
import { getSessionCookie } from 'better-auth/cookies'
export function middleware(req: NextRequest) {
const cookie = getSessionCookie(req) // cheap — cookie-only check that doesn't call the database on each request.
if (!cookie) {
return NextResponse.redirect(new URL('/sign-in', req.url))
}
return NextResponse.next()
}
export const config = { matcher: ['/dashboard'] }

13. Bonus: Let's update the sign-in page to look polished

Replace the ./app/(auth)/sign-in/page.tsx file with the following:

This includes smooth animations and satisfying hover effects, courtesy of Framer Motion and Gemini 2.5 Pro:

'use client'
import { authClient } from '@/lib/auth-client'
import { motion } from 'framer-motion'
import { useState } from 'react'
const SignIn = () => {
const [isHovering, setIsHovering] = useState(false)
const handleLogin = async () =>
authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' })
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-900 to-black overflow-hidden">
<div className="relative">
{/* Animated background elements */}
{[...Array(6)].map((_, i) => (
<motion.div
key={i}
className="absolute rounded-full bg-blue-500/10"
initial={{
width: `${50 + i * 20}px`,
height: `${50 + i * 20}px`,
x: -25 - i * 10,
y: -25 - i * 10,
opacity: 0.2,
}}
animate={{
scale: [1, 1.1, 1],
opacity: [0.2, 0.3, 0.2],
}}
transition={{
duration: 4 + i,
repeat: Infinity,
ease: "easeInOut",
delay: i * 0.5
}}
/>
))}
<motion.div
className="relative z-10 flex flex-col items-center justify-center rounded-xl bg-white/5 backdrop-blur-lg p-10 border border-white/10"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<motion.h1
className="mb-6 text-3xl font-bold text-white"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.8 }}
>
Welcome Back
</motion.h1>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.98 }}
onHoverStart={() => setIsHovering(true)}
onHoverEnd={() => setIsHovering(false)}
>
<button
onClick={handleLogin}
className="group relative flex items-center gap-2 rounded-lg bg-white px-8 py-3 text-lg font-medium text-gray-900 shadow-lg transition-all overflow-hidden"
>
<motion.div
className="absolute inset-0 bg-gradient-to-r from-blue-500 to-indigo-600"
initial={{ x: '-100%' }}
animate={{ x: isHovering ? '0%' : '-100%' }}
transition={{ duration: 0.3 }}
/>
<motion.span
className="relative z-10 text-gray-900 group-hover:text-white transition-colors duration-300"
>
Sign in with Google
</motion.span>
<motion.svg
xmlns="http://www.w3.org/2000/svg"
className="relative z-10 h-5 w-5 text-gray-900 group-hover:text-white transition-colors duration-300"
viewBox="0 0 24 24"
initial={{ rotate: 0 }}
animate={{ rotate: isHovering ? 360 : 0 }}
transition={{ duration: 0.5 }}
>
<path
fill="currentColor"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="currentColor"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="currentColor"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
/>
<path
fill="currentColor"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</motion.svg>
</button>
</motion.div>
<motion.div
className="mt-8 text-sm text-gray-400"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.8 }}
>
Tom's auth demo with Better Auth
</motion.div>
</motion.div>
</div>
</main>
)
}
export default SignIn
  1. Bonus: Update the home page to look better
  • Replace the ./app/page.tsx file with the following:
import Link from "next/link";
import { SignOutButton } from "../components/SignOutButton";
import { authClient } from "@/lib/auth-client";
export default async function Home() {
const { data: session, error } = await authClient.getSession()
if (error) {
console.error('Error fetching session:', error)
}
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-indigo-950 text-white">
<div className="max-w-5xl mx-auto px-4 py-16">
{/* Header with user info */}
<header className="flex justify-between items-center mb-16">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-500 rounded-full animate-pulse"></div>
<h1 className="text-2xl font-bold">Auth Demo</h1>
</div>
{session ? (
<div className="flex items-center gap-3 bg-white/10 backdrop-blur-sm rounded-full pl-3 pr-5 py-2 hover:bg-white/15 transition">
{session.user.image ? (
<img
src={session.user.image}
alt={`${session.user.name}'s profile`}
width={32}
height={32}
className="rounded-full ring-2 ring-blue-400"
/>
) : (
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center">
<span>{session.user.name?.[0]}</span>
</div>
)}
<div>
<p className="text-sm font-medium">{session.user.name}</p>
<div className="flex items-center justify-between">
<Link href="/dashboard" className="text-xs text-blue-300 hover:underline">
Dashboard →
</Link>
<span className="px-2"></span>
<SignOutButton />
</div>
</div>
</div>
) : (
<Link
href="/sign-in"
className="inline-flex items-center gap-2 bg-white/10 hover:bg-white/15 backdrop-blur-sm px-5 py-2 rounded-full transition-all"
>
<span>Sign In</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 5L21 12M21 12L14 19M21 12H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</Link>
)}
</header>
{/* Main content */}
<main className="flex flex-col items-center">
<div className="relative group mb-16">
<div className="absolute -inset-0.5 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-lg blur opacity-30 group-hover:opacity-80 transition duration-1000"></div>
<div className="relative bg-black/50 backdrop-blur-md p-8 rounded-lg border border-white/10">
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-center bg-clip-text text-transparent bg-gradient-to-r from-blue-400 to-indigo-400">
Next.js Google Authentication
</h2>
<p className="text-lg text-gray-300 text-center">
{session
? `You're signed in as ${session.user.name}. Explore the dashboard to see more.`
: "Experience seamless authentication with Google. Sign in to get started."}
</p>
<div className="mt-8 flex justify-center">
{session ? (
<Link
href="/dashboard"
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 rounded-lg px-6 py-3 text-white font-medium transition transform hover:scale-105"
>
View Dashboard
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</Link>
) : (
<Link
href="/sign-in"
className="inline-flex items-center gap-2 bg-gradient-to-r from-blue-500 to-indigo-600 hover:from-blue-600 hover:to-indigo-700 rounded-lg px-6 py-3 text-white font-medium transition transform hover:scale-105"
>
Sign in with Google
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" />
</svg>
</Link>
)}
</div>
</div>
</div>
{/* Features */}
<div className="w-full grid grid-cols-1 md:grid-cols-3 gap-6">
{[
{
title: "Secure Auth",
description: "OAuth 2.0 powered authentication with Google",
icon: (
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
)
},
{
title: "App Router",
description: "Built with Next.js App Router for modern routing",
icon: (
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
)
},
{
title: "User Profiles",
description: "Access user data including profile images",
icon: (
<svg className="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
)
}
].map((feature, index) => (
<div key={index} className="bg-white/5 backdrop-blur-sm p-6 rounded-lg border border-white/10 hover:bg-white/10 transition">
<div className="flex items-center gap-3 mb-3">
{feature.icon}
<h3 className="font-semibold text-lg">{feature.title}</h3>
</div>
<p className="text-gray-300">{feature.description}</p>
</div>
))}
</div>
</main>
</div>
</div>
);
}

12. Test it locally

pnpm dev

And visit http://localhost:3000/sign-in, press the Google button, and you should land on /dashboard with your name pulled from the Google profile.

13. Deploy

The next step is to deploy! I generally use Vercel, but you can use any other platform. Let me know if you want me to cover this.

The key points (if you're using Vercel) would be:

  • Run vercel deploy to link and deploy your app to Vercel
  • Add the same env vars from step 3 to your Vercel project, adding a new auth secret for production.
  • Update your Google OAuth authorization callback URL at the Google Cloud Console to your Vercel production URL (e.g. https://<your-vercel-app-name>.vercel.app/api/auth/callback/google).

Congrats

Congrats on adding Google sign-in to your Next.js app! I'd always be interested in seeing what you've built.

As mentioned, the full guide with code is below. It's also in a Github repo that you can clone and start using:

P.S Want to ship better features with AI?
Join my free weekly newsletter

Each week, I share bite-sized learnings and AI news that matters - so you can build better software in practice.

No spam guaranteed · Unsubscribe whenever