SStackforge
← Back to all posts

How to Implement a Login Module in Next.js with Supabase

nextjssupabaseauthi18n

title: "How to Implement a Login Module in Next.js with Supabase" date: "2026-09-30" excerpt: "A step-by-step walkthrough of building a login module in Next.js App Router with i18n and Supabase — Google OAuth, email OTP, and the auth callback route." tags: ["nextjs", "supabase", "auth", "i18n"] cta: "https://stacktechlab.dev" ctaText: "More posts like this"

In the last post I covered subdomain-based i18n. This one is the natural follow-up: how to actually build the login module on top of that i18n skeleton.

The stack is the same: Next.js 16 App Router, Supabase for auth, and our own tiny i18n layer (lib/i18n/). No auth library, no UI kit — just the pieces you need.

By the end you'll have:

  • A /login page that reads copy from the i18n messages object
  • A Google OAuth button
  • An email OTP flow (send code → enter code → sign in)
  • An /auth/callback route that handles the redirect from Supabase

Step 1: Build app/login/page.tsx

The page itself is a Server Component. It just renders the form.

// app/login/page.tsx
import LoginForm from './LoginForm'

export default function LoginPage() {
    return (
        <main className="flex min-h-screen items-center justify-center bg-[#0a0a0f]">
            <LoginForm />
        </main>
    )
}

Step 2: Build app/login/LoginForm.tsx

The form is a Client Component. It needs to be, because it:

  • Manages local state (email, loading, error)
  • Calls Supabase auth methods
  • Reads i18n messages via the useI18n() hook
// app/login/LoginForm.tsx
'use client'

import { useState } from 'react'
import { useI18n } from '@/lib/i18n/client'

export default function LoginForm() {
    const { messages } = useI18n()
    const t = messages.auth
    const [email, setEmail] = useState('')

    return (
        <div className="w-full max-w-md rounded-2xl border border-white/10 bg-[#14141a] p-8">
            <h1 className="mb-2 text-center text-3xl font-bold text-white">
                {t.welcomeBack}
            </h1>
            <p className="mb-8 text-center text-sm text-white/50">
                {t.welcomeDesc}
            </p>

            <button className="mb-4 w-full rounded-xl border border-white/10 bg-[#1c1c24] py-3 text-white hover:bg-[#23232d]">
                {t.loginWithGoogle}
            </button>

            <div className="my-6 flex items-center gap-3 text-xs text-white/30">
                <div className="h-px flex-1 bg-white/10" />
                <span>{t.or}</span>
                <div className="h-px flex-1 bg-white/10" />
            </div>

            <div className="space-y-4">
                <div>
                    <label className="mb-2 block text-sm text-white/70">
                        {t.email}
                    </label>
                    <input
                        type="email"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        placeholder={t.emailPlaceholder}
                        className="w-full rounded-xl border border-white/10 bg-[#1c1c24] px-4 py-3 text-white placeholder-white/30 outline-none focus:border-purple-500"
                    />
                </div>

                <button className="w-full rounded-xl bg-gradient-to-r from-purple-600 to-fuchsia-600 py-3 font-semibold text-white shadow-lg shadow-purple-600/30 hover:opacity-90">
                    {t.sendCode}
                </button>
            </div>
        </div>
    )
}

A few things worth pointing out:

  • LoginForm.tsx is a Client Component ('use client').
  • It reads messages through useI18n(), which is fed by the I18nProvider in app/layout.tsx.
  • Every string — t.welcomeBack, t.loginWithGoogle, t.or — maps to an auth.* key in lib/i18n/messages/base/en.json.
  • Because this is a Client Component, this step also proves that the i18n layer works on the client side, not just on the server.

Step 3: Visit /login to verify

npm run dev

Open http://localhost:3002/login. You should see the login card with:

  • "Welcome Back" / "Start your journey today"
  • "Sign in with Google"
  • "or"
  • "Email" input
  • "Send Verification Code" button

If all strings render correctly, the i18n + form layer is done. Now let's wire up the actual authentication.

Step 4: The Supabase Browser Client

You almost certainly already have this from your Supabase setup. It looks like:

// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
    return createBrowserClient(
        process.env.NEXT_PUBLIC_SUPABASE_URL!,
        process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
    )
}

The key detail: call createClient() fresh inside each handler. Don't cache it at module level. This is the standard @supabase/ssr pattern.

Step 5: Wire Up Google OAuth and Email OTP

Replace app/login/LoginForm.tsx with this fuller version:

// app/login/LoginForm.tsx
'use client'

import { useState } from 'react'
import { useI18n } from '@/lib/i18n/client'
import { createClient } from '@/lib/supabase/client'

export default function LoginForm() {
    const { messages } = useI18n()
    const t = messages.auth

    const [email, setEmail] = useState('')
    const [loading, setLoading] = useState(false)
    const [message, setMessage] = useState('')
    const [error, setError] = useState('')

    // ---------- Google OAuth ----------
    async function handleGoogle() {
        setLoading(true)
        setError('')
        const supabase = createClient()
        const { error } = await supabase.auth.signInWithOAuth({
            provider: 'google',
            options: {
                redirectTo: `${window.location.origin}/auth/callback`,
            },
        })
        if (error) {
            setError(error.message)
            setLoading(false)
        }
        // On success the browser is redirected to Google, so we never reach here
    }

    // ---------- Email OTP ----------
    async function handleSendCode(e: React.FormEvent) {
        e.preventDefault()
        if (!email) return

        setLoading(true)
        setError('')
        setMessage('')

        const supabase = createClient()
        const { error } = await supabase.auth.signInWithOtp({
            email,
            options: {
                emailRedirectTo: `${window.location.origin}/auth/callback`,
            },
        })

        if (error) {
            setError(error.message)
        } else {
            setMessage(t.codeSentTo.replace('{email}', email))
        }
        setLoading(false)
    }

    return (
        <div className="w-full max-w-md rounded-2xl border border-white/10 bg-[#14141a] p-8">
            <h1 className="mb-2 text-center text-3xl font-bold text-white">
                {t.welcomeBack}
            </h1>
            <p className="mb-8 text-center text-sm text-white/50">
                {t.welcomeDesc}
            </p>

            {/* Google button */}
            <button
                type="button"
                onClick={handleGoogle}
                disabled={loading}
                className="mb-4 flex w-full items-center justify-center gap-2 rounded-xl border border-white/10 bg-[#1c1c24] py-3 text-white hover:bg-[#23232d] disabled:opacity-50"
            >
                {t.loginWithGoogle}
            </button>

            <div className="my-6 flex items-center gap-3 text-xs text-white/30">
                <div className="h-px flex-1 bg-white/10" />
                <span>{t.or}</span>
                <div className="h-px flex-1 bg-white/10" />
            </div>

            {/* Email OTP form */}
            <form onSubmit={handleSendCode} className="space-y-4">
                <div>
                    <label className="mb-2 block text-sm text-white/70">
                        {t.email}
                    </label>
                    <input
                        type="email"
                        value={email}
                        onChange={(e) => setEmail(e.target.value)}
                        placeholder={t.emailPlaceholder}
                        required
                        disabled={loading}
                        className="w-full rounded-xl border border-white/10 bg-[#1c1c24] px-4 py-3 text-white placeholder-white/30 outline-none focus:border-purple-500 disabled:opacity-50"
                    />
                </div>

                <button
                    type="submit"
                    disabled={loading || !email}
                    className="w-full rounded-xl bg-gradient-to-r from-purple-600 to-fuchsia-600 py-3 font-semibold text-white shadow-lg shadow-purple-600/30 hover:opacity-90 disabled:opacity-50"
                >
                    {loading ? t.sending : t.sendCode}
                </button>
            </form>

            {/* Feedback */}
            {message && (
                <p className="mt-4 text-center text-sm text-green-400">{message}</p>
            )}
            {error && (
                <p className="mt-4 text-center text-sm text-red-400">{error}</p>
            )}
        </div>
    )
}

Two auth paths are wired up:

  • Google OAuth — supabase.auth.signInWithOAuth({ provider: 'google' }). On success the browser navigates to Google, then back to /auth/callback.
  • Email OTP — supabase.auth.signInWithOtp({ email }). Supabase sends a code. The user pastes it in the next step.

You'll notice the email flow returns a "we sent a code to..." message but the form doesn't yet have a field to enter the code. That's the next step in the full flow — see the follow-up section below.

Step 6: The Auth Callback Route

When OAuth or a magic link returns to your app, Supabase redirects to /auth/callback?code=.... You need a Route Handler to exchange that code for a session.

Create app/auth/callback/route.ts:

// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'

export async function GET(request: Request) {
    const { searchParams, origin } = new URL(request.url)
    const code = searchParams.get('code')
    const next = searchParams.get('next') ?? '/'

    if (code) {
        const supabase = await createClient()
        const { error } = await supabase.auth.exchangeCodeForSession(code)
        if (!error) {
            return NextResponse.redirect(`${origin}${next}`)
        }
    }

    return NextResponse.redirect(`${origin}/login?error=auth`)
}

Notes:

  • createClient() here is the server client from lib/supabase/server.ts — it reads/writes cookies, which is how the session gets persisted.
  • exchangeCodeForSession(code) is the crucial call. It swaps the one-time code from the URL for a real session, written as a cookie.
  • On success we redirect to next (default /). On failure we bounce back to /login with an error query param.

Full Email OTP Flow (Two Steps)

The snippet above only sends the code. To complete the login you also need a step where the user enters the 6-digit code and you call verifyOtp.

const { error } = await supabase.auth.verifyOtp({
    email,
    token: code,
    type: 'email',
})

If error is null, the user is signed in. router.push('/') plus router.refresh() sends them home with a fresh server-rendered session.

Verifying the Login

After the user signs in (Google or email OTP), open DevTools → Application → Cookies. You should see a cookie named:

sb-<your-project-ref>-auth-token

That cookie is the session. On every subsequent request, the Supabase server client reads it and you can call:

const { data: { user } } = await supabase.auth.getUser()

from any Server Component to get the current user.

What's Next

Once login works, the natural follow-ups are:

  1. A user menu — show the signed-in email and a logout button in the navbar.
  2. Route protection — redirect unauthenticated users away from /dashboard (or whatever your private pages are).
  3. Session refresh in proxy.ts — make sure Supabase tokens get renewed on every request.

That's the whole login module: one page, one form, one route handler, and the i18n you already had.

Login page running locally at localhost:3002/login


If you're building something similar — or if you've found a cleaner pattern for Supabase auth in Next.js App Router — I'd love to hear about it.