Passwordless Authentication with Next.js App Router and Supabase

Passwordless Authentication with Next.js App Router and Supabase

I always wanted to build something with a passwordless login, as I think it's the most convenient way to establish a session for the users. I've seen the practice in a few products I am using and I thought to try it out. In this article, I will cover the technical implementation of the logic, and I will not express my opinion on whether the magic links impact the conversion or have any impact on the number of users whatsoever.

As I said, one of the perks of the passwordless login is the convenience, both for the user and its technical implementation, but that assumes that there is a third-party service in use for the logic. After all, who wants to implement everything custom?

So kicked off a small weekend project just to feel the water. I have an idea where this should take me, so it could easily turn into a full-blown project, but let's go step by step.

For this particular example, I chose Supabase, because it is a service that I admire, it's well documented and has a huge community behind it.

Another reason why I started this small project was because I have never done anything with Next.js App Router. I have read about it, I have seen the divided opinions of people, but I have never experienced the hype or the hate around it. You can easily apply similar if not the same logic for a Next.js page router, it all depends on what you want to achieve.

What is Next.js' App Router?

It is an internal routing system provided by Next.js that enables declarative and client-side navigation between different pages within a Next.js application. It simplifies the management of page-based routing and provides optimized navigation for server-rendered React applications.

The first difference I noticed was the project structure. If for the pages router, you have a structure like src/pages/…, for the app router you'd have (e.g. feature1) src/app/feature1/page.js, and if you have another feature that'd be src/app/feature2/page.js. That said, all page implementation files are page.js, with different parent folders, together with the other files necessary for the implementation.

That has a direct impact on how the users navigate through the app. If for example, you want to define your index (/) page, you need to add it to the src/app root folder. If you want to define a yoururl.com/user/ page, you'd add it to src/app/user/page.js, and if you want to define the yoururl.com/user/settings URL, you need to have a src/app/user/settings/page.js file.

This is nothing but a superficial explanation of the differences between both routing systems. There are many and more fundamental differences along the way, and the best way to find them out is to read the docs and build something.

Next.js Middleware

The next thing I stumbled upon was Next.js' Middleware. It's nothing new or related to the App Router but I've never used it, I barely understood what its purpose is. So I explored ways how I can incorporate a middleware logic for the magic link login flow. One thing led to another and finally, I had it.

A middleware is a function that gets executed before each request that matches certain criteria. You can define that criteria by using a Regex or simply the path segment of the route. If you want the middleware to apply only to /login, you can define that through an internal configuration in the middleware.

This is extremely useful as you can implement a protective logic around routes and calls. For example, you can define a logic that prevents the user from accessing the “/dashboard” route in case the session has expired. So in this case, the middleware will be used for authentication purposes, but not limited to it.

API Routes

The API routes again are nothing new in Nextjs, and for this example, we will use them for the callback URL that gets called after a successful authentication.

So imagine this scenario.

  1. the user puts an email in an input field

  2. they press a button to initiate the login process

  3. they receive an email asking them to confirm their email

  4. they click on the confirmation link

  5. they are taken to the original website, if all went successfully, to the defined route for authenticated users.

The API routes come in place between steps 4 and 5. When the user clicks on the confirmation URL, Supabase sends a request to the URL defined via Supabase Auth configuration.

To configure this, go to your Supabase project → Authentication → URL Configuration

This URL then decides what to do after successful authentication, which most probably would take the user wherever we want them to go, after they establish a session.

Now you might be wondering if we use the route for this, what do we use the middleware for? We use it to keep the session active. We do that by requesting the session via its cookie, and with that refreshing the session. On each page change the middleware will refresh the session so that the user never gets logged out after a successful login.

Of course, if the logic requires you to do something differently, like make rainbows, you do it here.

In Supabase terminology, the passwordless login method is called OTP. Once you create your Supabase project, you need to go to the authentication management tab → Providers and make sure the Email provider is enabled. This is by default so most probably nothing to do here, just do a sanity check.

Next, there is a small catch. Go to Project Settings → Authentication → Allow new users to sign up. Make sure it's toggled.

For me, this was on by default, but I encountered a 403 Error the first time I tried to verify the login. Later I discovered that you can disable the toggle, save, enable again, save.

That solved the problem.

Putting it all together

Pick the root page.js in your project and define it like this:

import LoginForm from "./components/login_form";

export const metadata = {
  title: `Login Page`,
  description: `Passwordless Login`,
};

export default async function Login() {
  return <LoginForm />;
}

You see that the page is defined with a metadata config, so it cannot be marked use client, meaning using the local state here will not be possible. Instead, isolate the login views in a new component, say LoginForm, and make the magic happen there instead.

The LoginForm component should contain all local state changes like the input values, dispatching the event, reacting to the result, business as usual.

Let's define the LoginForm like this:

export default function LoginForm() {
  // local state
  const [email, setEmail] = useState();

  // global state
  const state = useSelector((state) => state.login);
  const dispatch = useDispatch();

  // navigation
  const router = useRouter();

  const onChange = (e) => {
    setEmail(e.target.value);
  };

  const onClicked = () => {
    if (email) dispatch(requestSessionAction({ email: email }));
    router.refresh();
  };

  return (
    <div className="flex flex-col w-1/3 items-center">
      <div className="flex flex-col bg-white p-14 space-y-4 mb-2">
        <div className="flex flex-col space-y-2">
          <p>
            We'll send a link to your email so that you create your account or login.
          </p>
        </div>
        <input type="email" id="email" name="email" onChange={onChange} placeholder="your email"/>
        <button
          onClick={onClick}
          type="submit"> 
            <span>Send Link</span>
        </button>
        {state.error && <p>Error occured</p>}
      </div>     
    </div>
  );
}

As I am using Redux for my state management, the action (requestSessionAction) will finally execute the login in the async thunk, where the Supabase call should be executed:

export const loginWithLink = createAsyncThunk(
  "action/login",
  async (params, thunkAPI) => {
    try {
      return await supabase.auth.signInWithOtp({
        email: params.email,
        options: {
          shouldCreateUser: true,
          emailRedirectTo: `${window.location.origin}/auth/callback`,
        },
      });
    } catch (error) {
      return thunkAPI.rejectWithValue(error.message);
    }
  }
);

As you see, we tell Supabase to redirect to the /auth/callback route of the current location origin, which is the API route I explained about above:

To create the route, create a file in the app/auth/callback folder named route.js:

export async function GET(request) {
  const requestUrl = new URL(request.url);
  // Supabase returns a code as part of the query params,
  // that needs to be parsed and used for the verification
  const code = requestUrl.searchParams.get("code");

  // if the code is there
  if (code) {
    const cookieStore = cookies();
    const supabase = createRouteHandlerClient({ cookies: () => cookieStore });
    // verify the session with it
    await supabase.auth.exchangeCodeForSession(code);
  }

  // URL to redirect to after sign in process completes
  return NextResponse.redirect(`${requestUrl.origin}/dashboard`);
}

The redirect clause defines where the user should be taken next, after successful login. In this case, I tell it to go to a route called /dashboard.

What remains is the definition of the middleware. Create a middleware.js file in src/ with this content (taken from Supabase implementation examples):

export async function middleware(req) {
  const res = NextResponse.next();
  // Create a Supabase client configured to use cookies
  const supabase = createMiddlewareClient({ req, res });

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

  // if user signed in, take them to /dashboard
  if (user && req.nextUrl.pathname === "/") {
    return NextResponse.redirect(new URL("/dashboard", req.url));
  }

  // user not signed in, take them to the root which is the login page
  if (!user && req.nextUrl.pathname !== "/") {
    return NextResponse.redirect(new URL("/", req.url));
  }

  return res;
}

// ensure the middleware is only called for these paths
export const config = {
  matcher: ["/", "/dashboard"],
};

This is where the logic wraps up.

Having the route and the middleware already in place delegates the logic outside the UI/the react components. This is great as we don't have to do per-page checks if the session is active or if we need to redirect the user somewhere else.

As you see the passwordless logic is not easy only for the user but also for the developer. It's great that in case other providers come in place (say, Google SignIn), the logic will just extend and/or be pretty much similar to this one.

As an exercise, you can try to that, implement Google as an additional provider.

Best of luck!