npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

nextjs-ward

v1.0.0

Published

Password protection for Next.js — drop-in components, hooks, and middleware

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-ward

Peer 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.js config — 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 an HttpOnly cookie. It also handles logout via a hidden action=logout field.
  • Session tokens are self-contained: {expiresAt}.{HMAC-SHA256(password, expiresAt)}. No database required. Changing the password immediately invalidates all existing sessions.
  • LoginForm submits via fetch and 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 calls router.push(?next). The form also works without JavaScript via a standard POST fallback.