# Shadcn Login Page: Magic Link, Codes, 2FA and Recovery

> Build a shadcn login page in Next.js with password, magic link, email codes, social sign-in, two-step codes and password reset, with every error state wired.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 16, 2026
- Category: [Build it: screens and apps](https://wingo-ui.com/blog/category/build)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/shadcn-login-page

## TL;DR

A production shadcn login page needs eight views (sign in, link sent, email code, two-step code, forgot, reset sent, new password, done) and an error state for each. The five official shadcn/ui login blocks are static forms with no submit handler as of October 2026, so that logic is yours to write. This tutorial wires the Wingo UI Login Page block, which has all eight views and their errors, to Next.js Server Actions that return errors as data.

A shadcn login page usually starts as a block you copy in an afternoon and ends as a week of edge cases. The form posts, then someone mistypes their password twice, asks for a reset link, opens it on their phone, or signs in with Google and gets a spinner that never stops. shadcn/ui ships five login blocks (login-01 to login-05), but they are layouts. The form in login-03, for example, has no `onSubmit`, no error message and a "Forgot your password?" link that points to `#` (checked on October 16, 2026 in the [shadcn/ui login blocks](https://ui.shadcn.com/blocks/login)). Galleries such as [shadcnblocks](https://www.shadcnblocks.com/blocks/login) and [Tailark](https://tailark.com/blog/login-blocks) add more layouts, and wiring them to your auth backend is still your job.

This tutorial builds a complete Next.js login page with the [Login Page block](https://wingo-ui.com/components/login-page) from Wingo UI, the library we build, so we are not neutral. It covers password sign-in, magic links, email codes, social providers, two-step verification and password recovery, plus the error states most templates skip.

## What does a production login page need?

Eight screens, even if the person only ever sees two of them:

1. **Sign in.** Email, password, "Remember me", a forgot password link, social buttons, and a switch to a passwordless method.
2. **Check your email.** After a magic link: the address, a resend button that waits, and a way to use another address.
3. **Enter the code.** A 6 digit code sent by email, for passwordless sign-in.
4. **Two-step verification.** The authenticator code after a correct password, with a backup code fallback.
5. **Forgot password.** One email field.
6. **Reset link sent.** A message that never confirms whether the account exists.
7. **Set a new password.** Strength rules and a confirmation field.
8. **Password changed.** A clear way back to sign in.

The Login Page block renders all eight as views of one component (`sign-in`, `magic-link-sent`, `otp`, `two-factor`, `forgot`, `forgot-sent`, `reset`, `reset-done`). Each view slides in from the side it belongs to, focus moves to its title and the change is announced to screen readers. On phones the form fills the screen with 48px fields and the hero is gone.

> Live demo (Login Page): Sign in with any email and a password shorter than 8 characters to see the error alert, then press Forgot password: the email you typed carries over. Try it at [Login Page](https://wingo-ui.com/components/login-page) and install it with `npx wingo-ui@latest add login-page`.

## How do you install the login page block?

One CLI command copies the block and everything it imports into your project:

```bash
npx wingo-ui@latest add login-page
```

The CLI copies `components/blocks/login-page.tsx`, its layout frame and its sample data into your project as source, plus the pieces it imports: [Password Input](https://wingo-ui.com/components/password-input) for the reveal toggle and Caps Lock warning, [OTP Input](https://wingo-ui.com/components/otp-input) for the code cells, Input, Button, Alert, Checkbox, Toast, the `use-cooldown` hook and the form validation helpers. It also installs `motion`, `lucide-react`, `react-hook-form` and `zod`. The file starts with `"use client"`. A Server Component can render it, but the props you need in production (`onSuccess` with the router, `linkComponent={Link}`, handlers that turn returned errors into rejections) are functions defined in the browser, so in practice you wrap it in a small client component.

Unlike a static Tailwind login page, the block keeps its state, validation and view changes inside the file, and every class uses the theme tokens (`bg-background`, `text-muted-foreground`, `border-border`), so it follows your light and dark themes without edits. Because it is source, you can still change any line.

## How do you connect it to Next.js Server Actions?

Every handler prop (`onSignIn`, `onMagicLink`, `onSendCode`, `onVerifyCode`, `onForgot`, `onReset`, `onProvider`) can return a promise. While it is pending, the button shows a spinner. When it rejects, the message of the error goes into the red alert above the form. When it resolves, the block moves to the next view or calls `onSuccess`.

The [Next.js authentication guide](https://nextjs.org/docs/app/guides/authentication) uses Server Actions for sign-in logic because they always run on the server, and the [error handling guide](https://nextjs.org/docs/app/getting-started/error-handling) says to model expected errors, such as a wrong password, as return values instead of throwing them (both checked October 16, 2026). There is a practical reason too: in a production build, Next.js replaces the message of an error forwarded from the server with a generic one plus a `digest` id, so sensitive details do not leak ([error.js reference](https://nextjs.org/docs/app/api-reference/file-conventions/error)). A "Wrong code" thrown inside the action would reach the alert as noise. The action returns its error as data, and the client turns it into a rejection for the block.

### The Server Actions

```ts
// app/(auth)/actions.ts
"use server";

import type { SignInValues, VerifyCodeContext } from "@/components/blocks/login-page";
// a thin wrapper you write around Better Auth, Auth.js or your own session code
import { auth } from "@/lib/auth";

export type ActionResult = { error?: string; twoFactor?: boolean };

export async function signIn(values: SignInValues): Promise<ActionResult> {
  const result = await auth.signInWithPassword(values);
  if (!result.ok) return { error: "The email or the password is not correct" };
  return { twoFactor: result.needsSecondFactor };
}

export async function sendMagicLink(email: string): Promise<ActionResult> {
  await auth.sendMagicLink(email);
  return {};
}

export async function sendCode(email: string): Promise<ActionResult> {
  await auth.sendEmailCode(email);
  return {};
}

export async function verifyCode(code: string, context: VerifyCodeContext): Promise<ActionResult> {
  // two-factor checks the pending sign-in that signIn started; otp checks the emailed code
  const result = await auth.verifyCode({ code, kind: context.view, backup: context.backup });
  if (!result.ok) return { error: `Wrong code. ${result.attemptsLeft} attempts left.` };
  return {};
}

export async function requestReset(email: string): Promise<ActionResult> {
  // same answer whether or not the account exists
  await auth.requestPasswordReset(email);
  return {};
}

export async function resetPassword(token: string, password: string): Promise<ActionResult> {
  const result = await auth.resetPassword({ token, password });
  if (!result.ok) return { error: "This reset link has expired. Request a new one." };
  return {};
}
```

The `auth` object stands for whatever library you use. The block does not care how the session is made, only whether the promise resolves or rejects.

### The client screen

```tsx
// app/(auth)/login/login-screen.tsx
"use client";

import { useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { AUTH_PROVIDERS, LoginPage, type LoginView } from "@/components/blocks/login-page";
import { requestReset, sendCode, sendMagicLink, signIn, verifyCode, type ActionResult } from "../actions";

// a returned error becomes a rejection, so the block shows it in its alert
function check(result: ActionResult) {
  if (result.error) throw new Error(result.error);
  return result;
}

export function LoginScreen() {
  const router = useRouter();
  const [view, setView] = useState<LoginView>("sign-in");
  // set by signIn when this account has two-step verification on
  const needsCode = useRef(false);

  return (
    <LoginPage
      view={view}
      onViewChange={setView}
      methods={["password", "magic-link", "otp"]}
      providers={AUTH_PROVIDERS.filter((provider) => provider.id !== "microsoft")}
      linkComponent={Link}
      registerHref="/signup"
      onSignIn={async (values) => {
        needsCode.current = check(await signIn(values)).twoFactor === true;
      }}
      onMagicLink={async (email) => check(await sendMagicLink(email))}
      onSendCode={async (email) => check(await sendCode(email))}
      onVerifyCode={async (code, context) => check(await verifyCode(code, context))}
      onForgot={async (email) => check(await requestReset(email))}
      onProvider={(id) => {
        window.location.assign(`/api/auth/${id}`);
        // never settles: the spinner keeps turning while the browser leaves
        return new Promise(() => {});
      }}
      onSuccess={() => {
        if (needsCode.current) {
          needsCode.current = false;
          setView("two-factor");
          return;
        }
        router.push("/dashboard");
      }}
    />
  );
}
```

```tsx
// app/(auth)/login/page.tsx
import { LoginScreen } from "./login-screen";

export default function Page() {
  return <LoginScreen />;
}
```

That server file is the whole Next.js login page route, and everything interactive lives in the client screen. Three choices in that code are worth explaining.

**Two-step only for the accounts that have it.** The `twoFactor` prop is a plain boolean that sends every successful password sign-in to the code view. Most apps only know after the password check, so the screen controls `view` and switches to `two-factor` in `onSuccess` when the server said so. After the code passes, `onSuccess` runs again and the router moves on.

**Methods in order.** `methods={["password", "magic-link", "otp"]}` shows the password form first, with "Email me a sign-in link" and "Email me a code" under it. Put `"otp"` first and the page opens as a passwordless login UI. Pass `methods={[]}` with providers and only the provider buttons remain, which fits a company that uses single sign-on.

**Real links.** `linkComponent={Link}` makes the "Create one" link and the footer links use `next/link`. The Create one link goes to `registerHref`, where the [Register Page block](https://wingo-ui.com/components/register-page) handles sign-up with the same layout.

## Which error states should a login page handle?

At least nine, from a wrong password to a locked code input, and they are the part a static shadcn login block leaves out. Here is what each failure does in the Login Page, with the default English copy.

| Situation | What the person sees |
|---|---|
| Wrong email or password | Red alert above the form, email kept, focus back on the password; a second failure also clears the password |
| Caps Lock on while typing the password | An amber "Caps Lock is on" warning under the field |
| Network or unknown error | "Something went wrong. Try again." when the error has no message |
| Provider sign-in fails | The spinner stops on the pressed button and the alert shows the message |
| Wrong code | The cells shake and the alert shows your message, such as "Wrong code. 2 attempts left." |
| Three wrong codes | The input locks for 60 seconds with a live "Too many attempts. Try again in 0:59." |
| Resend pressed too soon | The button waits with "Resend in 0:24", and the wait survives a reload |
| Forgot password for an unknown email | The same "If there is an account for..." message as for a real one |
| Weak or mismatched new password | Strength meter, rules checklist and a confirmation that must match |

Two of these follow the [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html) directly: failed sign-ins get one generic message that never says which part was wrong, and the [Forgot Password Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html) asks for a consistent message for existing and non-existing accounts. Keep that promise on the server too: same response, similar timing.

The 60 second lock and the resend wait are client-side courtesy. They stop a person from hammering the button, and they do nothing against a script calling your endpoint. Rate limit codes, sign-ins and reset requests on the server, per account, and send the attempts left back in the error message so the alert tells the truth.

> Live demo (Login Page): Type 123456 to pass, or a wrong code three times to see the shake, the attempts left and the one minute lock. Then switch to a backup code. Try it at [Login Page](https://wingo-ui.com/components/login-page) and install it with `npx wingo-ui@latest add login-page`.

## Magic link or one-time code: which is better for passwordless login?

Offer a code when people switch devices or sit behind a corporate mail filter, and a link when they mostly sign in on the device that reads their email. Both send something to the inbox, so both are as safe as the email account; the difference is what the person does next.

A magic link is one click when the email opens on the same device. It breaks in two common cases: the person starts on a laptop and opens the email on a phone, or a corporate mail scanner opens the link first. Scalekit's [comparison of OTPs and magic links](https://www.scalekit.com/blog/otp-vs-magic-links-passwordless-authentication) notes that scanners can use up a single-use link before the person clicks it. A defensive pattern is a link that opens a page with a "Continue" button, and only the button's POST spends the token.

A code keeps the person in the tab where they started and works across devices. The OTP Input inside the block sets `autocomplete="one-time-code"`, so iOS and Android can offer the code from the message, and pasting "123 456" fills all six cells. The sixth digit submits by itself.

After either email is sent, the "Check your email" view shows the address in bold, an "Open Gmail" or "Open Outlook" button when the domain matches, a spam hint and the resend button. The wait comes from `resendSeconds` (30 by default) and is kept in local storage, so a reload does not reset it.

## How do you build the forgot and reset password flow?

The forgot view takes the email that was typed on the sign-in form, so nobody types it twice. `onForgot` sends the link, and when it resolves the block moves to the "forgot-sent" view.

The reset link opens a separate route. Read the token on the server and pass it to a small client screen that starts on the `reset` view:

```tsx
// app/(auth)/reset-password/page.tsx
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { ResetScreen } from "./reset-screen";

export default async function Page({ searchParams }: { searchParams: Promise<{ token?: string | string[] }> }) {
  const { token } = await searchParams;
  // the block has no "link expired" view, so an invalid token goes back to sign in
  if (typeof token !== "string" || !(await auth.isResetTokenValid(token))) redirect("/login");
  return <ResetScreen token={token} />;
}
```

```tsx
// app/(auth)/reset-password/reset-screen.tsx
"use client";

import { useRouter } from "next/navigation";
import { LoginPage } from "@/components/blocks/login-page";
import { resetPassword } from "../actions";

export function ResetScreen({ token }: { token: string }) {
  const router = useRouter();
  return (
    <LoginPage
      defaultView="reset"
      passwordMinLength={12}
      onReset={async (password) => {
        const result = await resetPassword(token, password);
        if (result.error) throw new Error(result.error);
      }}
      onSuccess={() => router.push("/login")}
    />
  );
}
```

`resetPassword` is the last Server Action in the actions file above. The page checks the token before it renders anything, because the block has no "link expired" view. A token that expires while the person types still fails safely: the action returns the error and the alert shows it. The reset view shows a strength meter and a checklist (length, lowercase, uppercase, a number, a symbol), and `onReset` only runs when the password has the minimum length, scores at least Fair and matches its confirmation. `passwordMinLength` sets the length rule (8 by default). OWASP treats passwords under 15 characters as weak when there is no second factor, so raise it if your app does not offer two-step verification.

The last view says "We signed you out on your other devices", so make that true on the server, and send a confirmation email. Its "Sign in" button calls `onSuccess`, which goes back to the sign-in route instead of signing the person in automatically, as the OWASP reset guidance suggests.

> Live demo (Login Page): Press Change password with empty fields to see every error, then type a new password and watch the strength meter and the checklist. Try it at [Login Page](https://wingo-ui.com/components/login-page) and install it with `npx wingo-ui@latest add login-page`.

## How do you customize the login page layout and copy?

Through props: everything visual has one, with a default.

- `layout`: `"split"` (form beside a hero from 1024px), `"centered"` or `"card"` (a raised card on a dotted backdrop).
- `heroImage` puts a photo behind the hero; `hero` replaces its content. The default hero is sample copy for a made-up CRM, so replace it.
- `logo`, `tone` (neutral by default, `"brand"` for your accent on the primary buttons), `radius` (`"sm"`, `"md"`, `"lg"`) and `size` (`"lg"`, the default, is 48px fields; `"md"` is 40px, 44px on touch screens).
- `labels` takes any string, merged over `DEFAULT_LOGIN_PAGE_LABELS`, which is how you translate the page. `locale` only sets the `lang` attribute, so translated copy still goes through `labels`.
- `providerLayout="row"` turns the stacked "Continue with Google" buttons into icon buttons side by side.

If you only need one piece in a form of your own, the parts work alone. A code step with the free OTP Input:

```tsx
"use client";

import { useState } from "react";
import { OtpInput, type OtpStatus } from "@/components/ui/otp-input";

export function CodeStep({ verify }: { verify: (code: string) => Promise<boolean> }) {
  const [status, setStatus] = useState<OtpStatus>("idle");
  return (
    <OtpInput
      length={6}
      autoFocus
      status={status}
      onChange={() => setStatus("idle")}
      onComplete={async (code) => setStatus((await verify(code)) ? "success" : "error")}
    />
  );
}
```

Our [React OTP input guide](https://wingo-ui.com/blog/react-otp-input) covers paste, SMS autofill and resend timers in more depth, and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) covers the field, label and error anatomy the block uses.

## What is the next step?

Install the blocks and replace the simulated default handlers with your Server Actions:

```bash
npx wingo-ui@latest add login-page register-page
```

The Login Page, the Register Page and the Password Input are part of Wingo UI Pro, which costs $8 a month, $80 a year or $150 once as of October 2026 ([see pricing](https://wingo-ui.com/pricing)); the OTP Input is free. For the screens that come after sign-in, the [Next.js admin dashboard tutorial](https://wingo-ui.com/blog/nextjs-admin-dashboard-tutorial) builds the app shell, and the [Build it category](https://wingo-ui.com/blog/category/build) collects the other full-screen tutorials.

## Components in this post

- [Login Page](https://wingo-ui.com/components/login-page) (Pro): A React login page with password, magic link or code sign-in, Google, Apple and Microsoft buttons, two-step codes and password recovery. Install: `npx wingo-ui@latest add login-page`
- [Register Page](https://wingo-ui.com/components/register-page) (Pro): A React register page for a CRM sign-up: an account with password rules, company details from the CUI, an email code and a welcome step. Install: `npx wingo-ui@latest add register-page`
- [OTP Input](https://wingo-ui.com/components/otp-input) (Free): A React OTP input for one-time verification codes and PINs, with digits that roll into place and a caret that slides between slots. Install: `npx wingo-ui@latest add otp-input`
- [Password Input](https://wingo-ui.com/components/password-input) (Pro): A password field with a caret-safe reveal toggle, a Caps Lock warning and, for sign-up, a strength meter, rules checklist and generator. Install: `npx wingo-ui@latest add password-input`

## FAQ

### Does shadcn/ui have a login page?

Yes. The shadcn/ui blocks page lists five login blocks (login-01 to login-05) and five signup blocks as of October 2026. They are layouts with no submit handler, no error state and no recovery views, so the auth flow is yours to build.

### Should a login error say whether the email exists?

No. OWASP recommends one generic message such as "Invalid user ID or password" for every failed sign-in, and the same answer on the forgot password form whether or not an account exists. The Login Page block follows both rules by default.

### Magic link or one-time code: which is better for passwordless login?

A code keeps people in your tab and survives corporate mail scanners, which can open a link and use up a single-use token before the person clicks it. A link is one click on the same device. The Login Page supports both through the methods prop, so you can offer one first and the other as a fallback.

### How do I show Server Action errors in a client login form?

Return the error from the Server Action as data, for example { error: "The email or the password is not correct" }, and throw it in the client handler. The Next.js docs say to model expected errors as return values, and a message thrown on the server does not reach the client intact in production. The Login Page shows the message of any rejection in its alert.

### Is the Wingo UI login page free?

The OTP Input it uses for codes is free. The Login Page block, the Register Page block and the Password Input are part of Wingo UI Pro, and all of them install with npx wingo-ui@latest add.

---

Source: https://wingo-ui.com/blog/shadcn-login-page
