Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

OAuth social login in Next.js App Router -- GitHub and Google sign-in without NextAuth

June 26, 2026
nextjsauthdrizzle-ormsaasoauth

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.

How OAuth works (in plain terms)

  1. User clicks "Sign in with GitHub"
  2. You redirect them to GitHub with your client_id and a redirect_uri
  3. GitHub asks the user to approve, then redirects back with a one-time code
  4. You exchange the code for an access_token
  5. You use the token to fetch the user profile (email, name, avatar)
  6. You upsert a row in your users table, issue a JWT, and redirect to the app

That is the whole flow. No magic.

Schema change

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.

Environment variables

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

Step 1 -- redirect the user to the provider

// 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())
}

Step 2 -- handle the callback

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:

  • First-time OAuth user -- creates a new row
  • Same provider, same account -- finds them by provider + id (fast path)
  • Email already exists from password auth -- links the OAuth account to the existing row

Step 3 -- add the buttons to your login page

<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.

What you did not have to build

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.

How fast is this in practice?

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.