DEV Community

Cover image for Google OAuth + Email Auth in Next.js — The Complete SaaS Authentication Guide (2026)
Esimit Karlgusta
Esimit Karlgusta

Posted on • Originally published at zero-to-saas.collabtower.com

Google OAuth + Email Auth in Next.js — The Complete SaaS Authentication Guide (2026)

Category: Authentication and Security
Primary Keyword: Google OAuth email authentication Next.js SaaS
Level: Beginner to Intermediate


Authentication is the front door of your SaaS. Get it wrong and users can't get in — or worse, the wrong users get in. Get it right and it becomes invisible: a smooth, trusted experience that sets the tone for everything that follows.

This guide covers the full authentication setup for a Next.js App Router SaaS: email and password login, Google OAuth, session management, protected routes, and secure API access. By the end, you'll have a production-ready auth layer you can drop into any project.

Login and signup forms for web app authentication


Why Authentication Is More Than Just a Login Form

Most tutorials show you how to render a login form. But in a real SaaS, authentication touches nearly every layer of your app:

  • Signup — creating a user record in your database
  • Login — verifying credentials and issuing a session
  • OAuth — delegating identity verification to Google
  • Session — persisting who is logged in across requests
  • Route protection — blocking unauthenticated access to the dashboard
  • API protection — securing your backend routes from anonymous requests
  • Role handling — distinguishing free users from paid subscribers

NextAuth.js (now Auth.js) handles most of this out of the box. Your job is to configure it correctly and wire it to your database and UI.


Setting Up NextAuth.js in the App Router

Start by installing the required packages:

npm install next-auth@beta @auth/mongodb-adapter mongoose bcryptjs
Enter fullscreen mode Exit fullscreen mode

Note: Use next-auth@beta for full App Router support. The stable v4 has limited support for the App Router's server components and route handlers.

Create your auth configuration file:

// lib/authOptions.js
import NextAuth from 'next-auth';
import GoogleProvider from 'next-auth/providers/google';
import CredentialsProvider from 'next-auth/providers/credentials';
import { MongoDBAdapter } from '@auth/mongodb-adapter';
import clientPromise from '@/lib/mongodbClient';
import connectDB from '@/lib/mongodb';
import User from '@/models/User';
import bcrypt from 'bcryptjs';

export const authOptions = {
  adapter: MongoDBAdapter(clientPromise),
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
    CredentialsProvider({
      name: 'credentials',
      credentials: {
        email: { label: 'Email', type: 'email' },
        password: { label: 'Password', type: 'password' },
      },
      async authorize(credentials) {
        await connectDB();
        const user = await User.findOne({ email: credentials.email });
        if (!user || !user.password) return null;

        const isValid = await bcrypt.compare(credentials.password, user.password);
        if (!isValid) return null;

        return { id: user._id.toString(), email: user.email, name: user.name };
      },
    }),
  ],
  session: { strategy: 'jwt' },
  callbacks: {
    async jwt({ token, user }) {
      if (user) token.id = user.id;
      return token;
    },
    async session({ session, token }) {
      if (token) session.user.id = token.id;
      return session;
    },
  },
  pages: {
    signIn: '/auth/login',
    error: '/auth/error',
  },
  secret: process.env.NEXTAUTH_SECRET,
};

export const { handlers, auth, signIn, signOut } = NextAuth(authOptions);
Enter fullscreen mode Exit fullscreen mode

Then expose the route handler:

// app/api/auth/[...nextauth]/route.js
import { handlers } from '@/lib/authOptions';
export const { GET, POST } = handlers;
Enter fullscreen mode Exit fullscreen mode

This single file handles every auth request — login, logout, OAuth callbacks, session checks — automatically.

Developer coding on laptop with code editor open


Configuring Environment Variables

Add these to your .env.local:

NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_random_secret_here

GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

MONGODB_URI=mongodb+srv://...
Enter fullscreen mode Exit fullscreen mode

To generate a secure NEXTAUTH_SECRET:

openssl rand -base64 32
Enter fullscreen mode Exit fullscreen mode

For GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, head to console.cloud.google.com, create a project, enable the Google OAuth API, and add http://localhost:3000/api/auth/callback/google as an authorized redirect URI.


The User Model: Bridging Auth and Your Database

NextAuth's MongoDB adapter automatically creates accounts, sessions, and verification_tokens collections. But you also need a users collection that your app controls — for subscription status, preferences, and other SaaS-specific data.

// models/User.js
import mongoose from 'mongoose';

const UserSchema = new mongoose.Schema(
  {
    name: { type: String },
    email: { type: String, required: true, unique: true },
    password: { type: String }, // null for OAuth users
    image: { type: String },
    stripeCustomerId: { type: String },
    subscriptionStatus: {
      type: String,
      enum: ['active', 'past_due', 'canceled', 'trialing', null],
      default: null,
    },
    role: { type: String, enum: ['user', 'admin'], default: 'user' },
  },
  { timestamps: true }
);

export default mongoose.models.User || mongoose.model('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

Key design decision: OAuth users have no password field. Email users have a hashed password. Your authorize function in CredentialsProvider checks for user.password before attempting bcrypt comparison — this prevents OAuth-only accounts from logging in via the credentials form.


Building the Signup Flow for Email Users

OAuth handles its own user creation automatically. For email/password, you need a signup API route:

// app/api/auth/signup/route.js
import connectDB from '@/lib/mongodb';
import User from '@/models/User';
import bcrypt from 'bcryptjs';

export async function POST(req) {
  await connectDB();
  const { name, email, password } = await req.json();

  if (!email || !password || password.length < 8) {
    return Response.json({ error: 'Invalid input' }, { status: 400 });
  }

  const existing = await User.findOne({ email });
  if (existing) {
    return Response.json({ error: 'Email already registered' }, { status: 409 });
  }

  const hashed = await bcrypt.hash(password, 12);
  await User.create({ name, email, password: hashed });

  return Response.json({ success: true }, { status: 201 });
}
Enter fullscreen mode Exit fullscreen mode

A few things worth noting here:

  • Always hash passwords with bcrypt before storing. Never store plaintext.
  • Salt rounds of 12 is the current recommended minimum for production.
  • Return generic errors on failure — don't confirm whether an email exists to prevent enumeration attacks.

Building the Login and Signup UI

With NextAuth configured, your login page just needs to call signIn:

// app/auth/login/page.jsx
'use client';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function LoginPage() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [error, setError] = useState('');
  const router = useRouter();

  async function handleEmailLogin(e) {
    e.preventDefault();
    const result = await signIn('credentials', {
      email,
      password,
      redirect: false,
    });

    if (result?.error) {
      setError('Invalid email or password');
    } else {
      router.push('/dashboard');
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-base-200">
      <div className="card w-full max-w-sm shadow-xl bg-base-100">
        <div className="card-body">
          <h2 className="card-title text-2xl font-bold">Sign in</h2>

          {error && <div className="alert alert-error text-sm">{error}</div>}

          <form onSubmit={handleEmailLogin} className="flex flex-col gap-3">
            <input
              type="email"
              placeholder="Email"
              className="input input-bordered"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              required
            />
            <input
              type="password"
              placeholder="Password"
              className="input input-bordered"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
            <button type="submit" className="btn btn-primary">
              Sign in with Email
            </button>
          </form>

          <div className="divider">OR</div>

          <button
            onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
            className="btn btn-outline gap-2"
          >
            <svg className="w-5 h-5" viewBox="0 0 24 24">
              <path fill="#4285F4" 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="#34A853" 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="#FBBC05" 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.93l2.85-2.22.81-.62z"/>
              <path fill="#EA4335" 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"/>
            </svg>
            Continue with Google
          </button>

          <p className="text-center text-sm mt-2">
            Don't have an account?{' '}
            <a href="/auth/signup" className="link link-primary">Sign up</a>
          </p>
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This gives you a clean DaisyUI login card with both authentication methods, error handling, and a redirect to the dashboard on success.

Code snippet of Next.js and Tailwind project


Protecting Routes with Middleware

The cleanest way to protect your dashboard is with Next.js middleware. It runs before any page renders and can redirect unauthenticated users server-side:

// middleware.js (root of project)
import { auth } from '@/lib/authOptions';

export default auth((req) => {
  const isLoggedIn = !!req.auth;
  const isOnDashboard = req.nextUrl.pathname.startsWith('/dashboard');

  if (isOnDashboard && !isLoggedIn) {
    return Response.redirect(new URL('/auth/login', req.nextUrl));
  }
});

export const config = {
  matcher: ['/dashboard/:path*'],
};
Enter fullscreen mode Exit fullscreen mode

This is a single file that silently redirects any unauthenticated request to /dashboard to the login page — before a single byte of the dashboard renders. No client-side flicker, no layout shift.


Securing API Routes

For API routes in your SaaS backend, check the session at the top of every handler:

// app/api/user/profile/route.js
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/authOptions';
import connectDB from '@/lib/mongodb';
import User from '@/models/User';

export async function GET(req) {
  const session = await getServerSession(authOptions);
  if (!session) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  await connectDB();
  const user = await User.findOne({ email: session.user.email }).select('-password');

  return Response.json({ user });
}
Enter fullscreen mode Exit fullscreen mode

Always exclude the password field when returning user data. The .select('-password') Mongoose modifier ensures it never leaves your server.


How This Fits Into the Zero to SaaS Journey

Authentication is the second major milestone in building a SaaS — right after project setup and right before connecting your database and billing layer. Without it, you can't identify who your users are, protect their data, or gate features behind a subscription.

If you're working through the Zero to SaaS course, this is where things start feeling real. You have a login page, a Google OAuth button, and a dashboard that only authenticated users can see. That's a real product foundation.

Once auth is working, the next step is wiring up Stripe subscriptions so you can start charging for access. Check out Build SaaS with Next.js and Stripe to continue the journey, or start from the beginning with the full Next.js SaaS tutorial.


Common Mistakes to Avoid

1. Using strategy: 'database' with the App Router
Database sessions require the adapter on every request, which adds latency. Use strategy: 'jwt' for serverless environments like Vercel — it's faster and scales better.

2. Not hashing passwords
This one is obvious but still happens. Always use bcrypt. Never MD5, never SHA-1, never plaintext.

3. Allowing credential login for OAuth-only accounts
If a user signed up with Google, they have no password. Your authorize function must check for user.password before calling bcrypt.compare, or it will throw a runtime error.

4. Forgetting to add the callback URL to Google Cloud Console
For production, you must add https://yourdomain.com/api/auth/callback/google to the authorized redirect URIs in Google Cloud. Missing this is the number one reason OAuth fails after deployment.

5. Exposing session data on the client without filtering
The JWT callback adds token.id to the session. Be deliberate about what ends up in the session object — don't forward sensitive fields like subscription details or internal IDs that clients don't need.


Pro Tips for Production

  • Add email verification for new email signups using NextAuth's built-in email provider or a service like Resend.
  • Rate limit your signup and login routes to prevent brute force attacks. Upstash Redis with @upstash/ratelimit is a clean serverless solution.
  • Set httpOnly and secure cookie flags — NextAuth does this by default in production when NEXTAUTH_URL uses HTTPS.
  • Log auth events — failed logins and new signups are worth tracking. Wire events.signIn and events.createUser in your NextAuth config to a logging service.
  • Test OAuth locally with ngrok if you need a real HTTPS callback URL for development.

Real-World Example: DevTrack SaaS

You're building DevTrack — a time tracking SaaS for freelance developers. Here's how auth plays out for two different users:

User A — Sarah (Google OAuth):
Sarah clicks "Continue with Google," approves the OAuth consent screen, and lands on the dashboard. NextAuth creates her user record automatically via the MongoDB adapter. No password stored. Her name and image are pulled from her Google profile.

User B — James (Email/Password):
James fills out the signup form with his email and a password. Your API route hashes it with bcrypt and saves it to MongoDB. He then logs in with those credentials. NextAuth's CredentialsProvider verifies the hash and issues a JWT session.

Both users end up with a session that contains { id, email, name }. Your dashboard doesn't need to care how they authenticated — it just checks session.user.


Action Plan: What to Build Next

  1. ✅ Install next-auth@beta, @auth/mongodb-adapter, and bcryptjs
  2. ✅ Create lib/authOptions.js with Google and Credentials providers
  3. ✅ Add all required environment variables to .env.local
  4. ✅ Set up Google OAuth credentials in Google Cloud Console
  5. ✅ Build the signup API route with bcrypt hashing
  6. ✅ Create the login page with email form and Google button
  7. ✅ Add middleware to protect /dashboard routes
  8. ✅ Secure all API routes with getServerSession
  9. 🔜 Add email verification for new signups
  10. 🔜 Connect auth to your subscription status check

Wrapping Up

Authentication in a Next.js SaaS isn't complicated — but it has a lot of moving parts that need to work together. You now have both email/password and Google OAuth wired up, a MongoDB-backed user model, protected routes via middleware, and secure API handlers.

The pattern is consistent across every route and component: get the session, check it exists, use it to identify the user. Everything else — subscriptions, dashboards, roles — builds on top of this foundation.

If you want to build this complete authentication layer as part of a full SaaS app — including Stripe billing, a Tailwind dashboard, and deployment to Vercel — the Zero to SaaS course walks you through it all, step by step.

The door is open. Let your users in.

Top comments (0)