Guides
Build guide

How to add magic-link login in Next.js

Turning magic link on takes one toggle. The decisions that follow - session length, which browser opens the link, and deliverability - take longer.

Dharmendra Jagodana6 min read

In short

Magic link is one of eight auth methods you toggle on in the BuildBase dashboard, so the Next.js side is the same signIn() call as any other method. The work is what follows: a 30-day session with no password behind it, and a link that often opens in a different browser than it was requested from.

Magic link is the auth method people ask for last and enable first, because the demo is so good. No password field, no reset flow, no "must contain a symbol".

The Next.js work is genuinely small. What follows it is not, and this guide is mostly about that: the link travels through email, which means it arrives in a browser you did not choose, at a time you did not pick, and there is no password behind it to re-establish who is holding the phone.

Before you start

A BuildBase organization with an email sender configured - Google, Mailgun or custom SMTP. Magic link is an email delivery before it is an auth method, so an org with no working sender has nothing to send. The redirectUrl below also has to be registered in your client's redirect URLs, as a full URL.

What you are building

A sign-in button that sends an email, and a Next.js app that is authenticated when the user comes back from it. When it works, the round trip looks like this: the user clicks sign in, the portal takes their email, the inbox gets a message within a few seconds, and tapping the link returns them to your app signed in.

Step one: turn it on

Magic link is one of the eight auth methods in the BuildBase dashboard, and enabling it is a toggle rather than a code change. The hosted portal renders whichever methods are on for your organization.

This is the part that surprises people, so it is worth stating plainly: there is no signInWithMagicLink() in the SDK. There is one signIn(), and the portal decides what to offer.

What you should see: open your auth portal URL in a private window and the email field now offers a passwordless option alongside whatever else you have enabled.

Step two: wire the provider

The provider setup is the same one every BuildBase app uses. Nothing here is magic-link specific, which is the point.

// app/providers.tsx
'use client';

import { ApiVersion } from '@buildbase/sdk';
import { SaaSOSProvider } from '@buildbase/sdk/react';

export default function Providers({ children }) {
  return (
    <SaaSOSProvider
      serverUrl="https://api.yourapp.com"
      version={ApiVersion.V1}
      orgId="your-org-id"
      auth={{
        clientId: 'pk_demo_placeholder',
        redirectUrl: 'http://localhost:3000/callback',
        callbacks: {
          getSession: async () => localStorage.getItem('sessionId'),
          handleAuthentication: async (code) => {
            const res = await fetch('/api/auth/verify', {
              method: 'POST',
              body: JSON.stringify({ code }),
            });
            const data = await res.json();
            return { sessionId: data.sessionId };
          },
          onSignOut: async () => localStorage.removeItem('sessionId'),
        },
      }}
    >
      {children}
    </SaaSOSProvider>
  );
}

localStorage is in that snippet because it is one line and it makes the example readable. It is also readable by any script on your page. If the app touches billing or personal data, move the session ID to an HttpOnly cookie set by /api/auth/verify and have getSession read it server-side. The sessions reference lays out the trade-off.

What you should see: no errors on first render, and useSaaSAuth() resolving to unauthenticated rather than hanging in loading.

Step three: the sign-in call

'use client';

import { useSaaSAuth } from '@buildbase/sdk/react';

export default function SignInButton() {
  const { signIn, isLoading, isAuthenticated } = useSaaSAuth();

  if (isLoading) return <Spinner />;
  if (isAuthenticated) return null;

  return <button onClick={() => signIn('/dashboard')}>Sign in</button>;
}

The argument to signIn() is where the user lands after the round trip. Pass it deliberately. The default sends them back to wherever they started, which is the wrong destination more often than you would expect - see the failure modes below.

What you should see: a click takes you to the portal, an email arrives, and the link brings you back to /dashboard with isAuthenticated true.

Step four: handle the return

handleAuthentication is already in the provider config above. Its job is to take the auth code and exchange it for a session on your backend, and if it throws, the SDK leaves the user unauthenticated rather than half-signed-in.

That failure is silent from the user's side. They tapped a link and ended up on a signed-out page. Render something in the unauthenticated branch of your callback route that says the link did not work and offers to send another, rather than showing a generic sign-in form that makes them think they imagined the whole thing.

The failure modes

These are the three that produce support tickets, in the order you will meet them.

The link opens in a different browser. Someone requests it on a laptop and taps it in their phone's mail client. They are signed in on neither. The hosted pages finish the sign-in with state saved in the browser that asked, so the link only completes there, and opening it on the phone uses it up. Tell people to open the link on the device they requested it from, on the screen that says the email is on its way, and make sending another link one click.

The email is slow, or not there. Every other auth method fails loudly. This one fails by nothing happening, and the user's model of "nothing happened" is usually "the site is broken". Two things help: say the address you sent to on the waiting screen, so a typo is visible, and check spam placement before launch rather than after. Transactional deliverability is its own subject, and magic link is the message where it matters most - a marketing email in spam costs an open, a sign-in link in spam costs the account.

The session outlives the intent. The default is 30 days, configurable per session and capped at 180 days. With a password, a long session is a convenience; with magic link there is no second factor and no credential to re-enter, so the session length is the method's whole security posture. Pick it on purpose. If you are running anything that touches money, treat the 30-day default as a ceiling to argue down rather than a starting point.

One more thing that is not a failure so much as a surprise: permissions refresh hourly, not instantly. Change someone's role in the dashboard and their signed-in session picks it up on the next refresh, not on the next click. That trips people up across every auth method, and it has its own guide.

What this does not give you

Magic link is a passwordless method, not a second factor. BuildBase does not ship MFA, and we do not ship SAML, so if your buyer's security review asks for either, magic link is not the answer to it. The full list of what is available is the eight methods in the dashboard, and it is worth reading before you promise anything: Email/Password, Google, LinkedIn, GitHub, Microsoft, Magic Link, Passkeys, and OAuth 2.0.

If the goal was phishing resistance rather than convenience, passkeys are the method to reach for. They are in the same toggle list, and a user can have both. One thing to settle first, though: a passkey is bound to the hostname of the auth domain that created it, so that choice is much harder to reverse than this one.

RemoteWait, our virtual queue product, runs on this auth stack, and queues are exactly the shape of product where passwordless helps: people sign in from a phone, once, in a hurry.

If the redirect comes back and immediately bounces you to sign-in again, that is the middleware redirect loop, not a magic-link problem. And once people are signed in, the next question is usually which workspace they land in.

Install

npm i @buildbase/sdk
auth
nextjs
passwordless

Frequently Asked Questions

Do I need different Next.js code for magic link than for password login?

No. Auth methods are enabled per organization in the dashboard and the hosted portal renders whichever ones are on, so signIn() is the same call either way. The code below works unchanged if you later add passkeys or turn passwords off.

What happens if the user opens the magic link in a different browser?

They are not signed in, in either browser. The hosted sign-in pages hand the session back to your app using state saved in the browser that asked for the link, so the link only completes there; opened anywhere else it is used up without signing anyone in. This is the failure mode worth designing for: someone requests the link on a laptop and taps it in their phone mail client. Say on your sign-in screen to open the link on the same device, and make it easy to send another.

How long does a magic-link session last?

The default session is 30 days, configurable per session and capped at 180 days. Magic link has no password to re-enter, so the session length is the entire security posture of the method - pick it deliberately rather than taking the default.

Ship it

Create a project and run this guide against your own workspace.

7-day free trialNo credit card requiredCancel anytime