Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelog
  1. Home
  2. Blog
  3. Build it: screens and apps
  4. Shadcn Login Page: Magic Link, Codes, 2FA and Recovery
Auf Deutsch lesen
  1. Blog
  2. Build it: screens and apps
  3. Shadcn Login Page: Magic Link, Codes, 2FA and Recovery
Build it: screens and apps
Build it: screens and apps

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

SR
Serban Rusu · Founder of Wingo UI
Oct 16, 2026 · 11 min read

On this page

0%
  1. What does a production login page need?
  2. How do you install the login page block?
  3. How do you connect it to Next.js Server Actions?
    1. The Server Actions
    2. The client screen
  4. Which error states should a login page handle?
  5. Magic link or one-time code: which is better for passwordless login?
  6. How do you build the forgot and reset password flow?
  7. How do you customize the login page layout and copy?
  8. What is the next step?
  9. FAQ
    1. Does shadcn/ui have a login page?
    2. Should a login error say whether the email exists?
    3. Magic link or one-time code: which is better for passwordless login?
    4. How do I show Server Action errors in a client login form?
    5. Is the Wingo UI login page free?

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.

Published Oct 16, 2026

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 (opens in a new tab)). Galleries such as shadcnblocks (opens in a new tab) and Tailark (opens in a new tab) 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 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.

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
$ npx wingo-ui@latest add login-page
ProLogin Page docs

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 for the reveal toggle and Caps Lock warning, 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 (opens in a new tab) uses Server Actions for sign-in logic because they always run on the server, and the error handling guide (opens in a new tab) 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 (opens in a new tab)). 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 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.

SituationWhat the person sees
Wrong email or passwordRed alert above the form, email kept, focus back on the password; a second failure also clears the password
Caps Lock on while typing the passwordAn 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 failsThe spinner stops on the pressed button and the alert shows the message
Wrong codeThe cells shake and the alert shows your message, such as "Wrong code. 2 attempts left."
Three wrong codesThe input locks for 60 seconds with a live "Too many attempts. Try again in 0:59."
Resend pressed too soonThe button waits with "Resend in 0:24", and the wait survives a reload
Forgot password for an unknown emailThe same "If there is an account for..." message as for a real one
Weak or mismatched new passwordStrength meter, rules checklist and a confirmation that must match

Two of these follow the OWASP Authentication Cheat Sheet (opens in a new tab) directly: failed sign-ins get one generic message that never says which part was wrong, and the Forgot Password Cheat Sheet (opens in a new tab) 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.

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
$ npx wingo-ui@latest add login-page
ProLogin Page docs

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 (opens in a new tab) 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.

Press Change password with empty fields to see every error, then type a new password and watch the strength meter and the checklist
$ npx wingo-ui@latest add login-page
ProLogin Page docs

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 covers paste, SMS autofill and resend timers in more depth, and the 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); the OTP Input is free. For the screens that come after sign-in, the Next.js admin dashboard tutorial builds the app shell, and the Build it category collects the other full-screen tutorials.

Components in this post

  • Login Page

    A React login page with password, magic link or code sign-in, Google, Apple and Microsoft buttons, two-step codes and password recovery.

    Pro
  • Register Page

    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.

    Pro
  • OTP Input

    A React OTP input for one-time verification codes and PINs, with digits that roll into place and a caret that slides between slots.

    Free
  • Password Input

    A password field with a caret-safe reveal toggle, a Caps Lock warning and, for sign-up, a strength meter, rules checklist and generator.

    Pro

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.

  • Authentication
  • Next.js
  • shadcn/ui
  • React
  • Tailwind CSS

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Comparisons and alternatives

Shadcn Blocks vs Components: When to Start From Which

Shadcn blocks vs components vs page templates: what each one saves you, what it costs to customize later, and a simple rule for where to start a new screen.

SRSerban Rusu·Oct 16, 2026·11 min read
Building UI with AI agents

Codex MCP Setup: Build React UI With Real Components

Codex MCP setup for React UI, tested on Codex CLI 0.162.1: the config.toml entry, the key variable, AGENTS.md rules, prompts that use real blocks, and fixes.

SRSerban Rusu·Oct 13, 2026·11 min read
Building UI with AI agents

Claude Code shadcn Setup: MCP Server, Skill and CLAUDE.md

Claude Code shadcn setup, tested on shadcn 4.21.4: the MCP server, the skill, a second registry in its own folder, a CLAUDE.md block and prompts that work.

SRSerban Rusu·Oct 9, 2026·12 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • Theme generator

Resources

  • Docs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI