What you will build
A /login route with an email and password form. Submitting runs a server action. A wrong password shows one message under the form and keeps the email typed in. The button says Signing in... while the server works, and a correct password lands on /dashboard.
The form posts even before JavaScript loads, because it is a plain form with an action. Tested on Next.js 15.5 with React 19 and Tailwind v4.
The password check
Start with a stand-in check so the page runs today. It accepts [email protected] with demo1234 and waits a moment so you can see the pending state. server-only makes the build fail if a client file ever imports it (npm i server-only).
import "server-only";
// Demo check so the page runs on its own. Replace it with your auth
// library before you ship (see "Swap in real auth" below).
export async function checkCredentials(email: string, password: string) {
await new Promise((r) => setTimeout(r, 600)); // feel the pending state
if (email === "[email protected]" && password === "demo1234") {
return { id: "user_demo", email };
}
return null;
}The server action
The action gets the previous state and the FormData. It returns an object for errors and calls redirect() when the sign-in works. Returning the email lets the form keep what the person typed.
"use server";
import { redirect } from "next/navigation";
import { checkCredentials } from "@/lib/auth";
export type LoginState = { error?: string; email?: string };
export async function signIn(_prev: LoginState, formData: FormData): Promise<LoginState> {
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
if (!email || !password) {
return { error: "Enter your email and password.", email };
}
const user = await checkCredentials(email, password);
if (!user) {
// Same message for a wrong email and a wrong password, so the form
// does not tell anyone which emails have an account.
return { error: "That email and password don't match.", email };
}
// Your auth library sets the session cookie here (see "Swap in real auth").
redirect("/dashboard");
}Use one message for both a wrong email and a wrong password. A message like No account with that email tells anyone which addresses have accounts.
The form with useActionState
useActionState gives you the state the action returned, a form action to pass to the form, and a pending flag. That pending flag is all you need for the button text and to stop double submits.
"use client";
import { useActionState, useState } from "react";
import { signIn, type LoginState } from "./actions";
export function LoginForm() {
const [state, action, pending] = useActionState<LoginState, FormData>(signIn, {});
const [show, setShow] = useState(false);
return (
<form action={action} noValidate className="space-y-4">
<div>
<label htmlFor="email" className="text-sm font-medium">Email</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
defaultValue={state.email}
aria-invalid={!!state.error}
aria-describedby={state.error ? "login-error" : undefined}
className="mt-1 w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2.5 outline-none transition focus:border-amber-300 focus:ring-4 focus:ring-amber-300/20"
/>
</div>
<div>
<label htmlFor="password" className="text-sm font-medium">Password</label>
<div className="relative mt-1">
<input
id="password"
name="password"
type={show ? "text" : "password"}
autoComplete="current-password"
required
aria-invalid={!!state.error}
aria-describedby={state.error ? "login-error" : undefined}
className="w-full rounded-lg border border-zinc-700 bg-zinc-900 px-3 py-2.5 pr-16 outline-none transition focus:border-amber-300 focus:ring-4 focus:ring-amber-300/20"
/>
<button
type="button"
onClick={() => setShow((s) => !s)}
aria-pressed={show}
className="absolute inset-y-0 right-2 my-auto h-8 rounded px-2 text-xs text-zinc-400 hover:text-zinc-100"
>
{show ? "Hide" : "Show"}
</button>
</div>
</div>
<p id="login-error" role="alert" className="min-h-5 text-sm text-red-400">
{state.error}
</p>
<button
type="submit"
disabled={pending}
className="w-full rounded-lg bg-amber-300 px-4 py-2.5 font-medium text-zinc-950 transition active:scale-[0.98] disabled:opacity-60"
>
{pending ? "Signing in..." : "Sign in"}
</button>
</form>
);
}The error paragraph has role="alert" so screen readers announce a failed sign-in, and both inputs point at it with aria-describedby. autoComplete="current-password" lets password managers fill the field. The show button is type="button", so it never submits the form.
The page and a small entrance
The page stays a server component and renders the client form inside a card.
import type { Metadata } from "next";
import { LoginForm } from "./LoginForm";
export const metadata: Metadata = { title: "Sign in" };
export default function LoginPage() {
return (
<main className="grid min-h-dvh place-items-center bg-zinc-950 px-5 text-zinc-100">
<div className="w-full max-w-sm rounded-2xl border border-zinc-800 bg-zinc-900/60 p-6 shadow-2xl motion-safe:animate-[rise_.5s_ease-out]">
<h1 className="text-2xl font-semibold">Welcome back</h1>
<p className="mt-1 text-sm text-zinc-400">Sign in to your account.</p>
<div className="mt-6">
<LoginForm />
</div>
</div>
</main>
);
}The card rises in once with a keyframe in your global CSS. motion-safe: skips it for people who turned on reduced motion.
@keyframes rise {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: none; }
}Swap in real auth
Keep the form and replace the stand-in check inside the action. With Supabase, for example, signInWithPassword checks the password and sets the session cookie in one call:
// app/login/actions.ts with Supabase instead of the demo check
const supabase = await createClient(); // your @supabase/ssr server client
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) return { error: "That email and password don't match.", email };
redirect("/dashboard"); // the session cookie is already setWith Auth.js, call its signIn("credentials", ...) inside the action instead. Whatever you use, protect /dashboard on the server too (in middleware or in the page), because a redirect after sign in does not stop anyone from typing the URL.
Using Cursor, v0 or Lovable
Cursor: paste the four files into your project, then ask it to replace checkCredentials with your auth provider. Keeping the action in its own file stops it from moving the check into the client.
v0: generated login forms often use an onSubmit handler with client state. Ask v0 for a server action with useActionState and paste this form as the shape you want.
Lovable: projects are React with Vite, not Next.js, so there are no server actions. Keep the form markup and call your Supabase client in an onSubmit handler instead.
Or use the ready-made piece
If you would rather start from a finished animated page, Spotlight Lamp Login is a whole sign-in screen: a desk lamp lights each field and floods the page when sign in works. It has the same parts as this guide (labels, autocomplete, show password, an error state) and takes your auth call as one prop, so the server action above plugs straight in.
Everything above is free to copy. The piece is for when you want the finished, animated version with its source and a PROMPT.md for Cursor, v0 and Lovable.
Questions
- Should a Next.js login page be a server or client component?
- Both. Keep page.tsx a server component and put only the form in a client component. The password check runs in a server action, so it never ships to the browser.
- How do I show a loading state on the login button?
- useActionState returns a pending flag as its third value. Disable the button and change its text while pending is true.
- How do I keep the email after a failed sign in?
- Return it from the server action with the error and use it as the input's defaultValue, like the email field in this guide.
- Does this work without JavaScript?
- Yes. The form has a server action as its action, so it posts and redirects before hydration. The pending text and show password button need JavaScript.