Supabase email OTP login

Passwordless sign-in with Supabase: the user types their email, gets a 6-digit code, and the animated code card checks it with verifyOtp and opens the app.

Get the code · $3React · Tailwind · TypeScript
Step-Up OTP Gate, preview

Use it in Next.js

app/login/page.tsx

"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { StepUpGate } from "@/components/StepUpGate";
import { createClient } from "@/lib/supabase/client";

export default function OtpLoginPage() {
  const router = useRouter();
  const supabase = createClient();
  const [email, setEmail] = useState("");
  const [sentTo, setSentTo] = useState<string | null>(null);
  const [error, setError] = useState("");

  async function sendCode(address: string) {
    setError("");
    const { error } = await supabase.auth.signInWithOtp({
      email: address,
      options: { shouldCreateUser: false }, // only people who already have an account
    });
    if (error) setError(error.message);
    else setSentTo(address);
  }

  if (!sentTo) {
    return (
      <main className="grid min-h-dvh place-items-center bg-[#0b0c10] text-white">
        <form
          onSubmit={(e) => {
            e.preventDefault();
            void sendCode(email);
          }}
          className="grid w-80 gap-3"
        >
          <label htmlFor="email" className="text-sm text-white/70">Email</label>
          <input id="email" type="email" required value={email} onChange={(e) => setEmail(e.target.value)}
            className="rounded-lg bg-white/10 px-3 py-2 outline-none focus:ring-2 focus:ring-[#C6F432]" />
          <button className="rounded-lg bg-[#C6F432] px-3 py-2 font-medium text-black">Email me a code</button>
          {error ? <p role="alert" className="text-sm text-red-400">{error}</p> : null}
        </form>
      </main>
    );
  }

  return (
    <StepUpGate
      title="Check your email"
      description={`Enter the 6-digit code we sent to ${sentTo}.`}
      successTitle="You're signed in"
      successDescription="Opening your dashboard."
      onVerify={async (code) => {
        const { error } = await supabase.auth.verifyOtp({ email: sentTo, token: code, type: "email" });
        return !error; // false shakes the cells and shows "That code didn't match"
      }}
      onResend={() => void sendCode(sentTo)}
      onCancel={() => setSentTo(null)}
      onVerified={() => {
        router.push("/dashboard");
        router.refresh();
      }}
    >
      {/* whatever should sit blurred behind the code card */}
      <div className="grid min-h-dvh place-items-center bg-[#0b0c10] text-white/40">Your dashboard</div>
    </StepUpGate>
  );
}

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_PUBLISHABLE_KEY!);
}

Good to know

  • Supabase sends a magic link by default. To send a code instead, put {{ .Token }} in the Magic Link email template in your Supabase dashboard, as Supabase's passwordless guide describes. Both use the same signInWithOtp call.
  • shouldCreateUser: false means only existing accounts get a code. Remove it and the same screen signs new people up too.
  • onVerify returns true or false. false makes the cells shake and shows "That code didn't match. Try again." and the user can type again straight away.
  • This is the browser half. For server components and middleware to see the session, add the cookie setup from Supabase's Next.js server-side auth guide.
  • Copy StepUpGate.tsx, step-up-gate.css and timeline.ts from react/src/components. The gate frosts whatever you pass as children, so put a preview of the page behind it.

Install

  1. Copy from the zipPut react/src/components into src/, then npm i framer-motion @supabase/supabase-js @supabase/ssr.
  2. Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.

Props

onVerify
(code) → true when Supabase accepts it
onResend
send a fresh code
onCancel
back to the email step
onVerified
runs after the unlock animation
title / description
card text
length
number of cells (6 for Supabase)

How qodesign compares

All comparisons, with prices read on each library's own site.

From the blog: OTP Input in React: Paste, Autofill and one-time-code That Work

Questions

Do I need a custom SMTP server?

For real users, yes. Supabase's default email service is for testing: when we checked it sent 2 messages an hour and only to your project team's addresses. Their SMTP guide shows how to plug in your own provider.

Can I use it for SMS codes?

Yes. Call signInWithOtp with phone instead of email and verifyOtp with type "sms". The card doesn't care where the code came from.

Can Cursor or v0 add it for me?

Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.

Can I use it in client work?

Yes, in personal and client projects. You can't resell the code itself as a kit.