nextjs-ward
v1.0.0
Published
Password protection for Next.js — drop-in components, hooks, and middleware
Maintainers
Readme
nextjs-ward
Password protection for Next.js. Drop in four files and your app is locked — no backend, no database, no boilerplate.
Sessions are stateless HMAC-SHA256 tokens stored in an HttpOnly cookie. No external dependencies.
Status: early development, API may change before 1.0.
Why
Sometimes you're building something and want to put it on a subdomain to share with a friend or colleague — but you don't want it indexed, crawled, or stumbled upon by anyone else. Vercel's password protection solves exactly this, but it's locked behind a paid plan.
nextjs-ward is the free, drop-in alternative. It's not trying to be a full authentication system — if your project needs real user accounts, role-based access, or anything beyond "only people with the password get in", reach for Clerk, Auth.js, or similar. This is deliberately a simple solution to a simple problem: keep curious people out while you're working on something, and let the right people in.
The API is heavily inspired by Clerk — a single setup file, drop-in components, and as little boilerplate as possible.
Installation
npm install nextjs-ward
# or
pnpm add nextjs-wardPeer dependencies: next >= 13, react >= 18, react-dom >= 18
Quick start
1. Create ward.ts — define your config once
// ward.ts
import { createWard } from "nextjs-ward/server";
export const { proxy, middleware, handlers, config } = createWard({
password: "my-secret-password",
});2. Add the proxy / middleware
// proxy.ts (Next.js 16+)
export { proxy, config } from "./ward";// middleware.ts (Next.js ≤ 15)
export { middleware, config } from "./ward";3. Add the route handler
// app/api/ward/route.ts
import { handlers } from "../../../ward";
export const { POST } = handlers;4. Add the login page
// app/login/page.tsx
import { LoginForm } from "nextjs-ward/client";
export default function Page() {
return (
<main>
<LoginForm />
</main>
);
}That's it. Every route is now protected. Unauthenticated visitors are redirected to /login, and returned to their original destination after a successful login.
Logging out
Drop LogoutButton anywhere in your app. The Server Component version auto-hides itself when there's no active session:
import { LogoutButton } from "nextjs-ward/server";
export default function Page() {
return (
<div>
<h1>Secret page</h1>
<LogoutButton />
</div>
);
}Need it inside client-only UI, like a dropdown menu? See the nextjs-ward/client version below.
Configuration
All options are passed to createWard():
| Option | Type | Default | Description |
|---|---|---|---|
| password | string \| string[] | — | Required. One or more valid passwords. |
| loginPath | string | "/login" | Path of the login page. |
| cookieName | string | "ward-session" | Name of the session cookie. |
| sessionDuration | number | 86400 | Session lifetime in seconds (24 hours). |
The route handler path is always /api/ward — place it at app/api/ward/route.ts in your project. It's fixed by design: a configurable path would need to be threaded into every component that posts to it (LoginForm, LogoutButton), which reintroduces the boilerplate this library exists to avoid. One convention, nothing to configure.
Multiple passwords
Pass an array to support multiple valid passwords. Useful for team access or rotating credentials without invalidating all existing sessions at once:
export const { proxy, handlers, config } = createWard({
password: ["password-2024", "password-2025"],
});Custom login path
export const { proxy, handlers, config } = createWard({
password: "secret",
loginPath: "/auth",
});Then place your login page at app/auth/page.tsx instead of the default app/login/page.tsx.
Route matching
config includes a default matcher that excludes Next.js internals and static assets. Export it as-is for most projects:
// proxy.ts
export { proxy, config } from "./ward";To protect only specific routes, override config.matcher in the proxy file:
// proxy.ts — only protect /dashboard and /admin
import { proxy } from "./ward";
export { proxy };
export const config = {
matcher: ["/dashboard/:path*", "/admin/:path*"],
};Component reference
LoginForm
An unstyled password form. Style it with className/style on the <form>, and target internal elements via data-slot attributes — works with plain CSS, CSS Modules, and Tailwind.
import { LoginForm } from "nextjs-ward/client";
<LoginForm
className="flex flex-col gap-4"
labels={{ placeholder: "Password", submit: "Sign in" }}
/>Optional pre-built styles
Import the included stylesheet for a clean stacked form with light and dark mode support. Add it to your root layout so it covers every page:
// app/layout.tsx
import "nextjs-ward/styles";Dark mode is controlled by a .dark class on <html>. If you're using next-themes or a similar library it will work automatically. The stylesheet has no dependency on Tailwind.
Every rule in the stylesheet is wrapped in :where(), so it carries zero CSS specificity. Any className you pass — a single Tailwind utility, a CSS Modules class, anything — always overrides it, regardless of which stylesheet happens to load last.
Data-slot attributes for custom CSS targeting:
| Element | data-slot |
|---|---|
| <form> | "ward-login-form" |
| Wrapper <div> | "ward-login-group" |
| Password <input> | "ward-login-input" |
| Submit <button> | "ward-login-submit" |
| Error <p> | "ward-login-error" |
/* Plain CSS */
[data-slot="ward-login-form"] { width: 100%; max-width: 20rem; }
[data-slot="ward-login-group"] { display: flex; flex-direction: column; gap: 0.5rem; }
[data-slot="ward-login-input"] { padding: 8px; border: 1px solid #ccc; border-radius: 4px; }
[data-slot="ward-login-submit"] { padding: 8px 12px; border: none; cursor: pointer; }
[data-slot="ward-login-error"] { color: red; font-size: 0.875rem; }{/* Tailwind arbitrary selectors */}
<LoginForm className="[&_[data-slot=ward-login-group]]:flex [&_[data-slot=ward-login-input]]:border-0" />| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | — | Applied to the <form>. |
| style | CSSProperties | — | Applied to the <form>. |
| labels | { placeholder?, submit? } | — | Custom text for the input placeholder and submit button. |
LogoutButton
A button that clears the session and redirects to the login page. It comes in two flavors:
nextjs-ward/server — a Server Component that checks the session cookie itself and renders nothing if there's no active session. Drop it straight into a page or layout for zero-config auto-hiding:
import { LogoutButton } from "nextjs-ward/server";
<LogoutButton className="btn-danger" redirectTo="/login">
Sign out
</LogoutButton>nextjs-ward/client — a plain Client Component that always renders unless you tell it otherwise. Use this one when you need to nest it inside client-only UI (a dropdown menu, a modal library, etc.) where a Server Component can't be imported directly. Control visibility yourself via authenticated:
import { LogoutButton } from "nextjs-ward/client";
<LogoutButton authenticated={isLoggedIn} className="btn-danger" redirectTo="/login">
Sign out
</LogoutButton>Both accept the same props, plus cookieName on the server version (if you've customized it in your WardConfig):
| Prop | Type | Default | Description |
|---|---|---|---|
| redirectTo | string | loginPath | Where to redirect after logout. |
| children | ReactNode | "Log out" | Button label. |
| className | string | — | Applied to the <button>. |
| style | CSSProperties | — | Applied to the <button>. |
| authenticated | boolean | always shown | Client version only. Pass false to hide the button. |
| cookieName | string | "ward-session" | Server version only. Which cookie to check. |
Low-level API
nextjs-ward/server also exports primitives for custom authentication flows:
import { createSession, clearSession, createWardMiddleware } from "nextjs-ward/server";createSession(password, config)
Validates a password and sets the session cookie. For use inside a server action or route handler.
"use server";
import { createSession } from "nextjs-ward/server";
import { redirect } from "next/navigation";
export async function login(formData: FormData) {
const ok = await createSession(formData.get("password") as string, {
password: process.env.SITE_PASSWORD!,
});
if (ok) redirect("/");
}Returns true on success, false if the password is wrong.
clearSession(config?)
Deletes the session cookie.
"use server";
import { clearSession } from "nextjs-ward/server";
import { redirect } from "next/navigation";
export async function logout() {
await clearSession();
redirect("/login");
}createWardMiddleware(config)
The underlying middleware factory, for composing Ward with your own middleware logic:
import { createWardMiddleware } from "nextjs-ward/server";
import type { NextRequest } from "next/server";
const ward = createWardMiddleware({ password: "secret" });
export async function proxy(request: NextRequest) {
// run your own logic first
return ward(request);
}How it works
createWard()returns a middleware function, a route handler, and a Next.jsconfig— all sharing a single config object.- The middleware runs on every matched request. It reads the session cookie, verifies the HMAC signature and expiry, and either passes the request through or redirects to the login page.
- The route handler (
POST /api/ward) validates the submitted password, mints a signed session token, and sets it as anHttpOnlycookie. It also handles logout via a hiddenaction=logoutfield. - Session tokens are self-contained:
{expiresAt}.{HMAC-SHA256(password, expiresAt)}. No database required. Changing the password immediately invalidates all existing sessions. LoginFormsubmits viafetchand shows the error inline without a page reload. On wrong password the handler redirects to?error=1— the form catches this in the response URL and sets local error state instead of navigating. On success it callsrouter.push(?next). The form also works without JavaScript via a standard POST fallback.
