The simplest way to add Google sign-in to your Next.js app ✍️
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:
Here's a demo of what we'll build:
When building OAuth, you have a choice of either:
- managing auth yourself in your existing database, or
- 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/clientpnpm 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
):
# BetterAuthBETTER_AUTH_SECRET=replace-with-64-random-hexBETTER_AUTH_URL=http://localhost:3000# Google OAuthGOOGLE_CLIENT_ID=xxx.apps.googleusercontent.comGOOGLE_CLIENT_SECRET=yyy# DatabaseDATABASE_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
- 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.
- Create a user with a password and database:
createuser -s ultra_starter_userpsql -c "ALTER USER ultra_starter_user WITH PASSWORD 'special-password';"createdb ultra_starter_db
- 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.
- Go to the Google Cloud Console.
- Create a new project. (You might need to click on your existing project to see this option.)
- Select the new project
- Go to the OAuth consent screen and complete the form
- Click to create Oauth client ID and secret.
- Select web application.
- 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.)
- Click create and download the JSON credentials file.
- Open the JSON file
- 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:
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"><buttononClick={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 (<buttononClick={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.dataif (!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.divkey={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.divclassName="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.h1className="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.divwhileHover={{ scale: 1.05 }}whileTap={{ scale: 0.98 }}onHoverStart={() => setIsHovering(true)}onHoverEnd={() => setIsHovering(false)}><buttononClick={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.divclassName="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.spanclassName="relative z-10 text-gray-900 group-hover:text-white transition-colors duration-300">Sign in with Google</motion.span><motion.svgxmlns="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 }}><pathfill="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"/><pathfill="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"/><pathfill="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"/><pathfill="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.divclassName="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
- 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 ? (<imgsrc={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>) : (<Linkhref="/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 ? (<Linkhref="/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>) : (<Linkhref="/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: