Most users abandon a signup form the moment they see it. They do not want to create another password. They want to click "Sign in with GitHub" and be inside your product in three seconds.
This post shows you how to add OAuth social login to a Next.js App Router SaaS -- specifically GitHub and Google -- without adding NextAuth, Clerk, or any auth library. The same JWT your app already issues on password login gets issued here, so your protected routes and getUserFromRequest() helper do not change at all.
If you built your auth on boilerplate.iteam-company.com, the pieces slot right in.
client_id and a redirect_uricodecode for an access_tokenusers table, issue a JWT, and redirect to the appThat is the whole flow. No magic.
Add two columns to your users table to track the OAuth provider and the external user ID:
// modules/user/user.schema.ts
export const userTable = pgTable("users", {
id: uuid("id").defaultRandom().primaryKey(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash"), // nullable -- OAuth users have no password
name: text("name"),
avatarUrl: text("avatar_url"),
provider: text("provider"), // "github" | "google" | null (password auth)
providerAccountId: text("provider_account_id"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
passwordHash becomes nullable because OAuth users never set a password. Run a migration after this change.
Add these to your .env:
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Register your app in the GitHub Developer Settings and Google Cloud Console. Set the callback URLs to:
https://yourapp.com/api/auth/callback/github
https://yourapp.com/api/auth/callback/google
// app/api/auth/oauth/[provider]/route.ts
import { NextRequest, NextResponse } from "next/server"
const PROVIDERS = {
github: {
authUrl: "https://github.com/login/oauth/authorize",
clientId: process.env.GITHUB_CLIENT_ID!,
scope: "user:email",
},
google: {
authUrl: "https://accounts.google.com/o/oauth2/v2/auth",
clientId: process.env.GOOGLE_CLIENT_ID!,
scope: "openid email profile",
},
}
export async function GET(
req: NextRequest,
{ params }: { params: { provider: string } }
) {
const provider = PROVIDERS[params.provider as keyof typeof PROVIDERS]
if (!provider) return NextResponse.json({ error: "Unknown provider" }, { status: 400 })
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback/${params.provider}`
const url = new URL(provider.authUrl)
url.searchParams.set("client_id", provider.clientId)
url.searchParams.set("redirect_uri", redirectUri)
url.searchParams.set("scope", provider.scope)
if (params.provider === "google") url.searchParams.set("response_type", "code")
return NextResponse.redirect(url.toString())
}
This route exchanges the code for a profile, upserts the user, and issues a JWT.
// app/api/auth/callback/[provider]/route.ts
import { NextRequest, NextResponse } from "next/server"
import { db } from "@/db/drizzle"
import { userTable } from "@/db/schema"
import { eq, and } from "drizzle-orm"
import { signJwt } from "@/lib/auth"
async function getGithubProfile(code: string, redirectUri: string) {
const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.GITHUB_CLIENT_ID,
client_secret: process.env.GITHUB_CLIENT_SECRET,
code,
redirect_uri: redirectUri,
}),
})
const { access_token } = await tokenRes.json()
const [profileRes, emailsRes] = await Promise.all([
fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${access_token}` } }),
fetch("https://api.github.com/user/emails", { headers: { Authorization: `Bearer ${access_token}` } }),
])
const profile = await profileRes.json()
const emails: { email: string; primary: boolean; verified: boolean }[] = await emailsRes.json()
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? profile.email
return { id: String(profile.id), email: primaryEmail, name: profile.name ?? profile.login, avatarUrl: profile.avatar_url }
}
async function getGoogleProfile(code: string, redirectUri: string) {
const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code,
grant_type: "authorization_code",
redirect_uri: redirectUri,
}),
})
const { access_token } = await tokenRes.json()
const profileRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
headers: { Authorization: `Bearer ${access_token}` },
})
const p = await profileRes.json()
return { id: String(p.id), email: p.email, name: p.name, avatarUrl: p.picture }
}
export async function GET(
req: NextRequest,
{ params }: { params: { provider: string } }
) {
const code = req.nextUrl.searchParams.get("code")
if (!code) return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/login?error=no_code`)
const redirectUri = `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback/${params.provider}`
let profile: { id: string; email: string; name: string | null; avatarUrl: string | null }
try {
profile = params.provider === "github"
? await getGithubProfile(code, redirectUri)
: await getGoogleProfile(code, redirectUri)
} catch {
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/login?error=oauth_failed`)
}
// Find by provider+id first, then upsert by email
let [user] = await db
.select()
.from(userTable)
.where(and(eq(userTable.provider, params.provider), eq(userTable.providerAccountId, profile.id)))
.limit(1)
if (!user) {
;[user] = await db
.insert(userTable)
.values({
email: profile.email,
name: profile.name,
avatarUrl: profile.avatarUrl,
provider: params.provider,
providerAccountId: profile.id,
})
.onConflictDoUpdate({
target: userTable.email,
set: {
name: profile.name,
avatarUrl: profile.avatarUrl,
provider: params.provider,
providerAccountId: profile.id,
updatedAt: new Date(),
},
})
.returning()
}
const token = signJwt({ sub: user.id, email: user.email })
return NextResponse.redirect(`${process.env.NEXT_PUBLIC_BASE_URL}/dashboard?token=${token}`)
}
The upsert handles three cases:
<div className="flex flex-col gap-2">
<a href="/api/auth/oauth/github" className="btn btn-outline">
Sign in with GitHub
</a>
<a href="/api/auth/oauth/google" className="btn btn-outline">
Sign in with Google
</a>
</div>
These are plain anchor tags -- no JavaScript needed for the initial redirect. When the user lands back on /dashboard?token=..., your existing token storage logic handles it exactly as it does after a password login.
No session store. No cookie management. No CSRF tokens. No refresh token rotation. The JWT your app already issues covers all of it. OAuth just adds a new path to establishing the user identity -- the downstream auth layer stays identical.
Adding both providers takes about 45 minutes end to end: register the OAuth apps, add the env vars, add the two routes, wire the buttons, run the migration. You walk away with sign-in options that remove the single biggest friction point in your signup funnel.
Clone boilerplate.iteam-company.com, add the two routes above, and have social login live before your next coffee break.