Skip to contentVibraUI
Shared pagesinstalls at /magic-link

Magic link

Sign in without a password: one address in, a sent screen that names where the link went without printing the address back, and a resend held shut behind a countdown.

Open the live page

AuthAdapter has no magic-link ceremony of its own, and actions.ts deliberately does not borrow requestPasswordReset for one: a consumer who swapped in a real backend would then be mailing password resets to whoever asked to sign in. So the action validates the address itself and returns the same AuthResult the rest of the page reads — that file is the seam, and the mailer goes in it. It always succeeds for a well-formed address and never says whether an account exists, because answering that over an unauthenticated form is how an address list gets enumerated; the sent screen is written conditionally to match. The address is masked to its first letter and its domain before it is shown back, since the screen the link lands on may not be the screen the person who typed it is looking at — maskEmail is exported and tested. The cooldown reads the clock once, in the handler that sent the link, never in render; the sent screen only ever exists after a submit, so it is mounted in the browser and there is no server render to disagree with, and Countdown still draws placeholders until it has a clock of its own. "Use a different address" remounts the request rather than unpicking useActionState, which has no reset. Composes AuthFrame (block-local), Callout, Input, Label, Button and Countdown.

Preview

Install

npx shadcn@latest add @vibra/auth-magic-link

Needs the @vibra registry in your components.json — set it up once.

Source

app/magic-link/page.tsx
import Link from "next/link"

import { Callout } from "@/components/ui/callout"

import { AuthFrame, type AuthLayout } from "./components/auth-frame"
import { MagicLinkForm } from "./components/magic-link-form"
import { BRAND, LINK_LIFETIME, SIGN_IN_HREF, SIGN_UP_HREF } from "./data"

/**
 * Sign in with a link instead of a password. A server component: only the form
 * and its cooldown are client islands, and the page renders outside AppShell,
 * so the frame owns the single `main`.
 */
export default function MagicLinkPage({ layout = "centered" }: { layout?: AuthLayout } = {}) {
  return (
    <AuthFrame
      title="Sign in without a password"
      description={`Give us the address on your account and we will email a link that signs you in. It works once and expires in ${LINK_LIFETIME}.`}
      brand={BRAND}
      layout={layout}
      footer={
        <>
          Prefer a password?{" "}
          <Link href={SIGN_IN_HREF} className="text-foreground underline underline-offset-4">
            Sign in
          </Link>
          {" · "}
          <Link href={SIGN_UP_HREF} className="text-foreground underline underline-offset-4">
            Create an account
          </Link>
        </>
      }
    >
      <Callout className="text-xs">
        This demo sends nothing: any well-formed address reaches the sent screen, and the link
        itself is where your own mailer goes.
      </Callout>

      <MagicLinkForm />
    </AuthFrame>
  )
}
app/magic-link/data.ts
/**
 * Everything this page reads. What it changes lives in `actions.ts` beside it,
 * where the adapter runs on the server; this file holds only the vocabulary the
 * page and its client island share.
 */

/** The workspace this card is branded for. */
export const BRAND = { name: "Northwind", initial: "N" }

export const SIGN_IN_HREF = "/sign-in"
export const SIGN_UP_HREF = "/sign-up"

/** How long a link is good for, said the way the page says it. */
export const LINK_LIFETIME = "15 minutes"

/** How long the resend stays shut after a link goes out, in seconds. */
export const RESEND_SECONDS = 45

/**
 * An address with everything but its first letter and its domain taken out:
 * `a…@northwind.example`.
 *
 * The sent screen confirms *where* the link went without printing the address
 * back at whoever is looking at the screen — which may not be the person who
 * typed it. Anything without an @ is handed back untouched: it is not an
 * address, so there is nothing to mask, and the caller has already refused it.
 */
export function maskEmail(email: string): string {
  const at = email.indexOf("@")
  if (at <= 0) return email
  return `${email[0]}…${email.slice(at)}`
}
app/magic-link/actions.ts
"use server"

import { isFormData, NOT_A_FORM, type AuthError, type AuthResult } from "@/lib/auth-adapter"
import { isEmail } from "@/lib/validation"

const invalidEmail: AuthError = {
  code: "invalid_input",
  field: "email",
  message: "Enter a valid email address.",
}

/**
 * Sends a single-use sign-in link to an address.
 *
 * `AuthAdapter` has no magic-link ceremony of its own, and this deliberately
 * does not borrow `requestPasswordReset` for one: a consumer who swapped in a
 * real backend would then be mailing password resets to anyone who asked to
 * sign in. So this is the seam — validate the address here, call your own
 * mailer here, and keep the `AuthResult` the page already reads.
 *
 * It always succeeds for a well-formed address, and never says whether an
 * account exists: answering that question over an unauthenticated form is how
 * an address list gets enumerated.
 */
export async function sendMagicLink(formData: FormData): Promise<AuthResult<{ sent: true }>> {
  if (!isFormData(formData)) return { ok: false, error: NOT_A_FORM }
  const raw = formData.get("email")
  const email = typeof raw === "string" ? raw.trim() : ""

  if (!isEmail(email)) return { ok: false, error: invalidEmail }

  return { ok: true, data: { sent: true } }
}
app/magic-link/components/auth-frame.tsx
import * as React from "react"

export type AuthLayout = "centered" | "split"

export type AuthFrameProps = {
  /** The page's only h1. */
  title: string
  description: React.ReactNode
  brand: { name: string; initial: string }
  /** Sits under the card, outside its frame — the way off this page. */
  footer?: React.ReactNode
  /**
   * Goes in the split panel, under the brand. Decoration inside decoration:
   * the panel is `aria-hidden`, and a "centered" frame has no panel to put it
   * in, so nothing here may be the only place the page says something.
   */
  aside?: React.ReactNode
  /** "centered" (default) is today's single card. "split" adds a brand panel beside the card from `lg` up. */
  layout?: AuthLayout
  children: React.ReactNode
}

/**
 * The frame this page sits in. Auth pages render outside AppShell, so nothing
 * else on the route owns a landmark: the frame carries the page's single
 * `main` and its single `h1`.
 *
 * `layout` belongs to the product this page is installed in rather than to the
 * page — a template says which one it wants, and the page forwards it. The
 * card is the same either way; "split" only sets a brand panel beside it, and
 * only from `lg`, where there is room for one. Below that the two layouts are
 * the same screen: a phone gets the card, never a panel stacked above it.
 *
 * Copied into each auth block rather than shared between them. A block
 * installs as a self-contained route, so it brings its own frame with it.
 */
export function AuthFrame({
  title,
  description,
  brand,
  footer,
  aside,
  layout = "centered",
  children,
}: AuthFrameProps) {
  const card = (
    <div className="flex w-full max-w-sm flex-col gap-5">
      <div className="flex items-center justify-center gap-2">
        <span
          aria-hidden="true"
          className="flex size-6 items-center justify-center rounded-md bg-brand text-2xs font-semibold text-brand-foreground"
        >
          {brand.initial}
        </span>
        <span className="text-sm font-medium tracking-tight">{brand.name}</span>
      </div>

      <section className="flex flex-col gap-5 panel p-6">
        <header className="flex flex-col gap-1.5">
          <h1 className="type-display text-3xl text-pretty">{title}</h1>
          <p className="text-sm text-pretty text-muted-foreground">{description}</p>
        </header>
        {children}
      </section>

      {footer ? <div className="text-center text-sm text-muted-foreground">{footer}</div> : null}
    </div>
  )

  if (layout === "split") {
    return (
      <main
        data-slot="auth-frame"
        data-layout={layout}
        className="flex min-h-svh bg-surface lg:grid lg:grid-cols-2"
      >
        {/* The panel stays in the accessibility tree: it carries the brand and
            whatever the page hands it through `aside` — sign-up's headline and
            highlights, for one — so only the mark and the hairline are hidden
            as decoration. A surface token rather than the primary colour, so it
            reads as a panel in both themes instead of inverting in the dark one. */}
        <aside
          data-slot="auth-frame-panel"
          className="hidden flex-col justify-center gap-6 bg-card border-r border-border px-12 py-10 text-card-foreground lg:flex"
        >
          <span
            aria-hidden="true"
            className="flex size-11 items-center justify-center rounded-lg bg-brand text-base font-semibold text-brand-foreground"
          >
            {brand.initial}
          </span>
          <div className="flex flex-col gap-3">
            <span className="type-display text-3xl">{brand.name}</span>
            <span aria-hidden="true" className="h-px w-16 bg-border" />
          </div>

          {aside}
        </aside>

        <div className="flex flex-1 items-center justify-center px-4 py-10">{card}</div>
      </main>
    )
  }

  return (
    <main
      data-slot="auth-frame"
      data-layout={layout}
      className="flex min-h-svh items-center justify-center bg-surface px-4 py-10"
    >
      {card}
    </main>
  )
}
app/magic-link/components/magic-link-form.tsx
"use client"

import * as React from "react"
import { MailCheckIcon } from "lucide-react"

import { type AuthResult } from "@/lib/auth-adapter"
import { Button } from "@/components/ui/button"
import { Countdown } from "@/components/ui/countdown"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"

import { sendMagicLink } from "../actions"
import { LINK_LIFETIME, RESEND_SECONDS, maskEmail } from "../data"

const EMAIL_ID = "magic-link-email"
const MESSAGE_ID = "magic-link-error"

/**
 * The confirmation's heading. It is its own component so that mounting it *is*
 * the event: the effect runs exactly when the confirmation replaces the form,
 * and never on the form's own mount. `tabIndex={-1}` makes it a focus target
 * without putting it in the tab order, so a reader whose focus was on the
 * submit button is moved to what replaced it instead of being dropped at the
 * top of the document.
 */
function ConfirmationHeading({ children }: { children: React.ReactNode }) {
  const ref = React.useRef<HTMLHeadingElement>(null)
  React.useEffect(() => {
    ref.current?.focus()
  }, [])

  return (
    <h2 ref={ref} tabIndex={-1} className="text-base font-semibold tracking-tight">
      {children}
    </h2>
  )
}

/**
 * The sent screen, and the cooldown that holds its resend shut.
 *
 * The clock is read once, here, at the moment the link went out — never in the
 * page's own render. This screen only ever exists after a submit, so it is
 * mounted in the browser and there is no server render for a `Date.now()` to
 * disagree with; `Countdown` still draws placeholders until it has a clock of
 * its own, so nothing flickers between markup and hydration either.
 */
function Sent({ email, onUseAnotherAddress }: { email: string; onUseAnotherAddress: () => void }) {
  const [until, setUntil] = React.useState(() => new Date(Date.now() + RESEND_SECONDS * 1000))
  const [ready, setReady] = React.useState(false)

  return (
    <div className="flex flex-col gap-5">
      <div className="flex flex-col items-start gap-3">
        <span
          aria-hidden="true"
          className="flex size-9 items-center justify-center rounded-full bg-success-muted text-success"
        >
          <MailCheckIcon className="size-4" />
        </span>
        <div className="flex flex-col gap-1.5">
          <ConfirmationHeading>Check your inbox</ConfirmationHeading>
          {/* Conditional on purpose, and masked: the action never says whether
              the address is registered, and the screen it lands on may not be
              the screen the person who typed it is looking at. */}
          <p role="status" className="text-sm text-pretty text-muted-foreground">
            If an account exists for <span className="text-foreground">{maskEmail(email)}</span>, a
            sign-in link is on its way. It works once and expires in {LINK_LIFETIME}.
          </p>
        </div>
      </div>

      <div className="flex flex-wrap items-center gap-x-3 gap-y-2 border-t pt-4">
        <Button
          type="button"
          variant="outline"
          size="sm"
          disabled={!ready}
          onClick={() => {
            setReady(false)
            setUntil(new Date(Date.now() + RESEND_SECONDS * 1000))
          }}
        >
          Send another link
        </Button>

        {ready ? (
          // Rendered only once the wait is over, so the region a reader is
          // listening to announces the change rather than repeating a number.
          <span aria-live="polite" className="text-xs text-muted-foreground">
            You can ask for another link.
          </span>
        ) : (
          <span className="flex items-center gap-1.5 text-xs text-muted-foreground">
            Another link in
            <Countdown
              to={until}
              format="clock"
              onComplete={() => setReady(true)}
              // The number is a figure in the sentence: its size, in the 500 weight.
              className="text-foreground"
              valueClassName="text-xs font-medium"
            />
          </span>
        )}
      </div>

      <Button type="button" variant="ghost" size="sm" onClick={onUseAnotherAddress}>
        Use a different address
      </Button>
    </div>
  )
}

/** One attempt at a link request: the form, or the screen that replaces it. */
function LinkRequest({ onUseAnotherAddress }: { onUseAnotherAddress: () => void }) {
  const [email, setEmail] = React.useState("")
  const [result, formAction, pending] = React.useActionState<
    AuthResult<{ sent: true }> | null,
    FormData
  >((_previous, formData) => sendMagicLink(formData), null)

  const error = result && !result.ok ? result.error : null

  if (result?.ok) return <Sent email={email} onUseAnotherAddress={onUseAnotherAddress} />

  return (
    <form action={formAction} noValidate className="flex flex-col gap-4">
      <div className="flex flex-col gap-1.5">
        <Label htmlFor={EMAIL_ID}>Email</Label>
        <Input
          id={EMAIL_ID}
          name="email"
          type="email"
          autoComplete="email"
          placeholder="you@example.com"
          required
          value={email}
          onChange={(event) => setEmail(event.target.value)}
          aria-invalid={error ? true : undefined}
          aria-describedby={error ? MESSAGE_ID : undefined}
        />
        {error ? (
          <p id={MESSAGE_ID} role="alert" className="text-sm text-danger">
            {error.message}
          </p>
        ) : null}
      </div>

      <Button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Email me a link"}
      </Button>
    </form>
  )
}

/**
 * The request form and the sent screen. `useActionState` has no reset, and
 * after a send there is nothing left to correct in place — so "use a different
 * address" remounts this component from the outside rather than unpicking its
 * state from within.
 */
export function MagicLinkForm() {
  const [attempt, setAttempt] = React.useState(0)
  return (
    <LinkRequest key={attempt} onUseAnotherAddress={() => setAttempt((count) => count + 1)} />
  )
}