How to build a login page in Next.js with server actions

A working sign-in page for the App Router in four files: a server action that checks the password, a client form that shows errors and a pending button with useActionState, and a redirect when it works. It runs with no auth library, then you swap in yours.

By Teodor Avadani · Updated October 25, 2026

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).

lib/auth.ts
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.

app/login/actions.ts
"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.

app/login/LoginForm.tsx
"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.

app/login/page.tsx
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.

app/globals.css
@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 (Supabase)
// 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 set

With 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.

Read next

Build a Login Page in Next.js with Server Actions · qodesign