DEV Community

Cover image for Add password-less OTP based authentication to your Next.js apps using Supabase & Twilio
ashish
ashish

Posted on

Add password-less OTP based authentication to your Next.js apps using Supabase & Twilio

User authentication is one of the most important aspects of a full stack app (web app / mobile app) and implementing it in your apps "used to be a hassle" during the early (stone-age) days but not anymore, thanks to tools like Supabase which make it much easier to add relatively tough to implement features like authentication in your apps.

There are a lot of different types of authentication used by apps - Email-Password, OAuth, Magic links, OTPs and some more stuff. But one of the most user-loved authentication type has got to be OTP based authentication as it's password-less & much much faster (let's be honest, we all hate creating and remembering passwords!). In this blog I'm going to show you how you can implement your own SMS-OTP based authentication in your Next.js projects using Supabase & Twilio so let's get started!

Prerequisites

Before we jump into the code part, I need you to quickly do the following stuff -

  1. Create a Supabase account if you don't have one already and create a new project. The most simple way to do that is by going to database.new.
  2. Create a Twilio account if you don't have one already & then head over to your console & get your free trail phone number - you can look at this guide by Twilio for help.
  3. Connect Supabase with Twilio
    1. Head over to your Twilio console , scroll down to view your account credentials. Twilio console
    2. Head over to your Supabase project dashboard. Use the sidebar to navigate to Authentication > Providers and enable Phone provider. Supabase dashboard
    3. You will be asked to enter the credentials you got in Step 1. You need to enter your trial phone number in the Twilio Message Service SID field.
    4. Click on save and we're done!
  4. Setup a new Next.js project with Supabase using this command.

    npx create-next-app -e with-supabase    
    
  5. Get your Supabase Project URL and API Key by going to your project dashboard and then navigating to Settings > API. Paste them both in a new .env file like this -
    Supabase settings

    NEXT_PUBLIC_SUPABASE_URL=your-project-url
    NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
    
  6. Run the app using the dev command.

    npm run dev
    
  7. If you take a look at the code now, you would see a basic Next.js app with some Supabase features have been added along with an Email-Password authentication. Now let's create our own password-less login flow.

Time to write some code!

If you want to have the Email-password auth setup navigate to /app/login/page.tsx file. Our SMS OTP auth flow would be quite similar! Let's start by creating a new route for this auth flow - for example /sms-login. Create a new folder named sms-login inside the app directory and create a new file named page.tsx inside it and one more file named actions.ts. Here's the folder structure -

app
|-- sms-login
|   |-- page.tsx // route code goes here
|   |-- actions.ts // server actions
Enter fullscreen mode Exit fullscreen mode

If you don't know what server actions are yet, you're missing out on the latest features of Next.js. Make sure you give the new docs a read. For now, just think of them as form mutations which run on the server.

Head over to actions.ts file and paste the following code (don't worry, I have explained the working of this code block below) -

// app/sms-login/actions.ts

"use server";

import { cookies } from "next/headers";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";

export const signIn = async (formData: FormData) => {
  "use server";
  const phone = formData.get("phone") as string;
  const cookieStore = cookies();
  const supabase = createClient(cookieStore);
  
  const { error } = await supabase.auth.signInWithOtp({
    phone,
  });

  if (error) {
    return redirect("/sms-login?message=Could not authenticate user");
  }
  
  return redirect("/sms-login?message=Check your phone for the OTP");
};

export const verifyOTP = async (formData: FormData) => {
  "use server";
  const phone = formData.get("phone") as string;
  const token = formData.get("token") as string;
  const cookieStore = cookies();
  const supabase = createClient(cookieStore);

  const { error } = await supabase.auth.verifyOtp({
    phone,
    token,
    type: "sms",
  });

  if (error) {
    console.error(error);
    return redirect("/sms-login?message=Could not authenticate user");
  }

  return redirect("/");
};
Enter fullscreen mode Exit fullscreen mode

Let's go through the code now. Notice the "use server"; notation at the top of the file, it specifies that this piece of code should strictly run only on the server! The next 3 lines are the imports we need - cookies util from next.js, createClient from supabase (the code for this exists in the /utils/supabase/server.ts file, explaining that would be beyond the scope of this article though.), & the new redirect util from next.js for redirecting users as we want to.
As you can see, we have two server actions signIn & verifyOTP defined in the file. The first few lines of bothe the actions are pretty similar where we fetch the data from our form using formData and setup our supabase client. Supabase provides two easy-to-use utilities for OTP auth - auth.signInWithOtp() & auth.verifyOtp(). The first one is used to send the OTP to the user's phone number while the other one is for verifying the OTP and finally signing-in the user.
The next few lines just check if there's an error or not and redirect the user accordingly.

Now, let's write the code for our frontend. Head over to the /sms-login/page.tsx file and paste the following code -

"use client";

import Link from "next/link";
import { signIn, verifyOTP } from "./actions";
import { useState, useEffect } from "react";

export default function SMSLogin({
  searchParams,
}: {
  searchParams: { message: string };
}) {
  const [otpSent, setOtpSent] = useState(false);

  return (
    <div className="flex-1 flex flex-col w-full px-8 sm:max-w-xl justify-center gap-2">
      <Link
        href="/"
        className="absolute left-8 top-8 py-2 px-4 rounded-md no-underline text-foreground bg-btn-background hover:bg-btn-background-hover flex items-center group text-sm"
      >
        <svg
          className="w-4 h-4 mr-2"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            className="inline-flex"
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth="2"
            d="M10 19l-7-7m0 0l7-7m-7 7h18"
          ></path>
        </svg>
        Back
      </Link>
      <h1 className="text-4xl font-bold">Sign in using phone number</h1>
      <p className="text-foreground">
        {searchParams.message || "Sign in to your account"}
      </p>
      <form
        action={async (formData) => {
          if (otpSent) {
            await verifyOTP(formData);
          } else {
            try {
              await signIn(formData);
              setOtpSent(true);
            } catch (e) {
              console.error(e);
            }
          }
        }}
        className="flex flex-col gap-2"
      >
        <label htmlFor="phone" className="flex flex-col gap-1">
          <span className="text-foreground">Phone</span>
          <input
            type="tel"
            name="phone"
            id="phone"
            className="rounded-md px-4 py-2 bg-inherit border mb-2"
            placeholder="+91 1234567890"
          />
        </label>
        <label
          htmlFor="token"
          className={`flex flex-col gap-1 ${otpSent ? "" : "hidden"}`}
        >
          <span className="text-foreground">Your OTP</span>
          <input
            type="text"
            name="token"
            id="token"
            required
            placeholder="123456"
            className={`rounded-md px-4 py-2 bg-inherit border mb-2`}
          />
        </label>
        <button
          type="submit"
          className="bg-green-700 rounded-md px-4 py-2 text-foreground mb-2 max-w-max hover:bg-green-600"
        >
          {otpSent ? "Verify OTP" : "Send OTP"}
        </button>
        {otpSent && <ExpirationTimer />}
      </form>
    </div>
  );
}

const ExpirationTimer = () => {
  const expirationTime = 60;
  const [timeLeft, setTimeLeft] = useState(expirationTime);

  let id: any = null;

  useEffect(() => {
    if (timeLeft > 0) {
      id = setTimeout(() => {
        setTimeLeft(timeLeft - 1);
      }, 1000);
    }
    return () => {
      clearTimeout(id);
    };
  }, [timeLeft]);

  return (
    <div className="flex justify-between items-center">
      <p className="text-foreground text-sm">
        {timeLeft > 0 ? `OTP expires in ${timeLeft} seconds` : "OTP expired!"}
      </p>
      <button
        className="text-foreground text-sm underline disabled:text-foreground/50 disabled:cursor-not-allowed"
        formAction={async (formData) => {
          await signIn(formData);
          setTimeLeft(expirationTime);
        }}
        disabled={timeLeft > 0}
      >
        Resend OTP
      </button>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Time to understand the code now. As you can see, the first line this time is "use client;" which denotes that this file will only run on the client side - it's a React Server Component . The next 3 lines again are imports we would need, notice how we have imported the server actions we created previously.
The main page component SMSLogin starts with a state definition otpSent. I'm using it to update the form with a new OTP input once the OTP has been sent. The most important part of this code is the <form/> component, everything else is just normal next.js frontend code you can understand easily. If you look at the form component you would see an action attr -

...
action={async (formData) => {
          if (otpSent) {
            await verifyOTP(formData);
          } else {
            try {
              await signIn(formData);
              setOtpSent(true);
            } catch (e) {
              console.error(e);
            }
          }
        }
      }
...
Enter fullscreen mode Exit fullscreen mode

Inside every form action attr you have access to the submitted formData. Inside the action code, We're first checking if the OTP has been sent already - If it has been sent we run the verifyOTP action and if not, we run the signIn action and set the otpSent state to true.
Inside the form component we have two input fields - phone & token/otp. Notice how the input field form OTP has been hidden until the otpSent state turns true. Also, we are using a single submit button for both sending and verifying OTP. In the last line of our form compoennt, you would see a new component <ExpirationTimer/> being conditionally rendered if the OTP has been sent.
The ExpirationTimer component has been defined in the last few lines of the code. It has two utilities -

  1. To show the remaining time until OTP expires.
  2. Allow user to resend OTP once it expires. The first line of the component defines the expiration time which is 60 sec (you can change it in the Supabase auth settings). Now we're using another state variable to find the time left and show it to the user. We have an effect running with a timeout function which updates the state variable every second. Let's look at the last part of the code which is the Resend OTP button -
<button
  className="text-foreground text-sm underline disabled:text-foreground/50 disabled:cursor-not-allowed"
  formAction={async (formData) => {
          await signIn(formData);
          setTimeLeft(expirationTime);
  }}
  disabled={timeLeft > 0}
>
  Resend OTP
</button>
Enter fullscreen mode Exit fullscreen mode

Notice how the button is disabled till the OTP expires (timeLeft > 0), The button has a formAction attr which is being used so that we have access to the same formData from the Login form. Inside the action we are simply calling the signIn action once again and resetting the timeLeft state so that the timer restarts.

With this our frontend & backend for the auth flow is ready! Now, we need to do one last thing - Head over to the /app/components/AuthButton.tsx page and change the Login link from /login to /sms-login like this -

<Link
  href="/sms-login"
  className="py-2 px-3 flex rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
>
  Login
</Link>
Enter fullscreen mode Exit fullscreen mode

Time to see our code in action

  • Start your dev server if you haven't yet (npm run dev) & head over to localhost:3000, this is what the home page would look like - home page
  • Click on the Login button on the top-right, this will take you to the login page you just made - login page
  • Now you just need to enter your phone number and click on Send OTP, and if you did everything correctly you would receive an OTP soon after on your phone via SMS. Enter the OTP in the input field and click on Verify OTP. Congratulations! You just made a completely password-less login flow using Supabase! auth flow

Resources

Conclusion

This blog assumes that you have at least some knowledge of Next.js app router (v14). I tried to explain every little thing in the best possible way but you may still have some doubts. Feel free to leave a comment below, I'll be happy to help you out!

Thanks a lot for reading!

Top comments (0)