Login & OTP Form Pattern
Live PreviewOpen in Storybook ↗
A two-step authentication flow — email/password followed by a numeric OTP — is a common requirement for secure web applications. This pattern wires up prokodo UI's Form, Input, InputOtp, Button, and Loading components into a complete client-side flow backed by a Next.js Server Action.
When to use this pattern
- Sign-in flows with MFA/2FA using a time-based or emailed OTP
- Any step-form where the second screen depends on the result of the first
Step 1 — Email & password form
components/auth/LoginForm.tsx
"use client"
import { useActionState } from "react"
import { useRouter } from "next/navigation"
import { Input } from "@prokodo/ui/input/client"
import { Button } from "@prokodo/ui/button/client"
import { Loading } from "@prokodo/ui/loading/client"
import "@prokodo/ui/input.css"
import "@prokodo/ui/button.css"
import "@prokodo/ui/loading.css"
type State = { step: "credentials" | "otp"; error?: string } | undefined
async function loginAction(_prev: State, formData: FormData): Promise<State> {
"use server"
const email = formData.get("email") as string
const password = formData.get("password") as string
const result = await verifyCredentials(email, password)
if (!result.ok) return { step: "credentials", error: result.error }
await sendOtp(email)
return { step: "otp" }
}
export function LoginForm() {
const [state, action, isPending] = useActionState(loginAction, undefined)
if (state?.step === "otp") return <OtpForm />
return (
<form action={action} style={{ display: "grid", gap: "1rem" }}>
<Input
name="email"
type="email"
label="Email address"
required
autoComplete="username"
errorText={state?.error}
/>
<Input
name="password"
type="password"
label="Password"
required
autoComplete="current-password"
/>
<Button
title={isPending ? "Signing in…" : "Sign in"}
type="submit"
variant="contained"
color="primary"
disabled={isPending}
fullWidth
loading={isPending}
/>
</form>
)
}
Step 2 — OTP verification
components/auth/OtpForm.tsx
"use client"
import { useActionState } from "react"
import { useRouter } from "next/navigation"
import { InputOtp } from "@prokodo/ui/input-otp/client"
import { Button } from "@prokodo/ui/button/client"
import "@prokodo/ui/input-otp.css"
import "@prokodo/ui/button.css"
type OtpState = { error?: string } | undefined
async function verifyOtpAction(
_prev: OtpState,
formData: FormData,
): Promise<OtpState> {
"use server"
const code = formData.get("otp") as string
const result = await verifyOtp(code)
if (!result.ok) return { error: "Invalid or expired code" }
// Set session cookie, then redirect
return undefined
}
export function OtpForm() {
const [state, action, isPending] = useActionState(verifyOtpAction, undefined)
return (
<form action={action} style={{ display: "grid", gap: "1rem" }}>
<p>Enter the 6-digit code we sent to your email.</p>
<InputOtp name="otp" length={6} autoFocus errorText={state?.error} />
<Button
title="Verify"
type="submit"
variant="contained"
color="primary"
disabled={isPending}
fullWidth
loading={isPending}
/>
</form>
)
}
Layout
app/(auth)/login/page.tsx
import { LoginForm } from "@/components/auth/LoginForm"
export default function LoginPage() {
return (
<main
style={{
display: "grid",
placeItems: "center",
minHeight: "100vh",
padding: "1rem",
}}
>
<div style={{ width: "100%", maxWidth: "400px" }}>
<h1>Sign in</h1>
<LoginForm />
</div>
</main>
)
}
Accessibility notes
InputOtpmanages focus movement automatically as each digit is entered.- Errors are linked to inputs via
aria-describedbyso screen readers announce them on focus. Loadinginside aButtonshould pair witharia-label="Signing in…"on the button to give screen readers meaningful feedback.
Related components
Form— Server Action integration, constraint validationInput— all input types, error and hint slotsInputOtp— digit-by-digit OTP entry with auto-advanceButton— loading and disabled statesLoading— inline spinner variants
Further reading
- Next.js Server Actions &
useActionState - OWASP Authentication Cheat Sheet
- prokodo Next.js agency — we build secure authentication flows