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

@nonext/husk

v0.18.5

Published

Nonext CMS (Husk): schema-driven CMS for Next.js and Firebase

Readme

@nonext/husk

Husk is a schema-driven CMS for Next.js and Firebase. It installs as a dependency and mounts a full admin interface at /admin inside your own app, with no separate service to host and no second deployment to keep in step.

The site stays a normal modern frontend. Husk adds an admin UI plus a typed read SDK, so an editor changes content without touching Firebase, code, or a deployment.

  • Content types generate the sidebar, the list views, the forms, the validation and the CRUD.
  • Code-defined types (TypeScript, in git, lockable) coexist with types created in the admin Schema Builder without a deploy.
  • Firebase stays behind an adapter. Nothing in the SDK names a collection or a document id.
  • One Firebase project per customer. No shared multi-tenant project.

Install

npm install @nonext/husk firebase

next, react and react-dom come from your Next.js app; firebase is installed next to the package. Tailwind CSS v4 is required but is not a peer dependency: init refuses a project without tailwindcss and @tailwindcss/postcss. firebase-admin (^14.0.0) and @modelcontextprotocol/sdk (^1.30.0) are optional peer dependencies: install firebase-admin for a real Firebase project (the token verifier and the claim writer use it), for MCP, preview and scheduled publishing, and for the CLI commands that read Schema Builder types (sync, upgrade, types, indexes and doctor unless you pass --offline, and always export and import). The MCP SDK is only for MCP (see the setup guide below). While the package is on 0.x the minor version is where breaking changes go, so pin the minor (~0.17.0) if you want an upgrade to be a decision rather than an accident.

Every GitHub release also carries a packed tarball. It is the same bytes npm serves, with no build step at install time, for a project that would rather not depend on the registry:

{ "dependencies": { "@nonext/husk": "https://github.com/nonext-at/nonext-husk/releases/download/v<version>/nonext-husk-<version>.tgz" } }

Peer dependencies

Supplied by the customer app, never bundled by this package:

| Peer | Range | |---|---| | next | ^16.0.0 | | react | ^19.0.0 | | react-dom | ^19.0.0 | | firebase | ^12.0.0 |

Optional, only for the features that need them: firebase-admin ^14.0.0 and @modelcontextprotocol/sdk ^1.30.0.

Two copies of React or of the Firebase SDK is two contexts, two app instances, and hooks that throw, so this is enforced by a test rather than left to convention.

Setting up Husk, end to end

This is the whole path from an empty folder to a customer editing content, in the order you do it. Each step says what to run and when it is needed. Steps marked real project only are skipped while you develop against the Firebase emulators.

 1. Prerequisites
 2. Create the Firebase project            (real project only)
 3. Create the Next.js project
 4. Install the packages
 5. Run `npx nonext-husk init`
 6. Set the environment variables
 7. Deploy the rules and indexes           (real project only, before anyone signs in)
 8. Create the first admin and sign in
 9. Define content types
10. Read content on the website
11. Settings and maintenance mode
12. Users, roles and the media library
13. MCP for AI agents                      (optional)
14. Deploy the site
15. Check the installation

1. Prerequisites

  • Node 22.18 or newer. nonext-husk doctor imports cms.config.ts directly, which needs the type stripping Node has had since 22.18.
  • Next.js 16 with the App Router, React 19 and Tailwind CSS v4 (required, not a peer dependency of this package: init refuses a project without tailwindcss and @tailwindcss/postcss rather than scaffolding an admin that renders unstyled).
  • A JDK 21, only if you develop against the Firebase Emulator Suite. The Firestore and Storage emulators are Java processes.
  • One Firebase project per customer. Husk never shares a project between customers.

You can start with no Firebase project at all: the default init points the app at the emulators, and steps 2 and 7 wait until you go live.

2. Create the Firebase project (real project only)

In the Firebase console:

  1. Create a project. Its id (for example acme-site) is what init asks for.
  2. Build, Firestore Database, Create database. Pick a location; it cannot be changed later. Start in production mode, because the rules in step 7 replace the defaults.
  3. Build, Authentication, Get started, Sign-in method. Enable Email/Password. That is the only provider Husk uses.
  4. Build, Storage, Get started, if you want the media library. Firebase may ask for the Blaze plan before it creates the default bucket; check what the console says for your project. Note the bucket name at the top of the Files tab (usually <project-id>.firebasestorage.app).
  5. Project settings, General, Your apps, Add app (Web). Copy the apiKey. It is not a secret; it identifies the project to the browser SDK.
  6. Project settings, Service accounts, Generate new private key. This is a secret. It goes in a server environment variable in step 6 and nowhere else.

Install the Firebase CLI and sign in, so you can deploy rules and indexes later:

npm install --save-dev firebase-tools
npx firebase login

3. Create the Next.js project

npx create-next-app@latest customer-site --typescript --tailwind --app --no-src-dir
cd customer-site

The App Router is required. --src-dir works too; init follows whichever layout it finds.

4. Install the packages

npm install @nonext/husk firebase

Add the optional peers only for the features that need them:

| Package | Install it when | |---|---| | firebase-admin (^14.0.0) | You use a real Firebase project (init --no-emulators), MCP, preview or scheduled publishing. It powers the token verifier and the claim writer. The CLI needs it too, even on the emulators, to read Schema Builder types: sync, upgrade, types, indexes and doctor unless you pass --offline, and always export and import. | | @modelcontextprotocol/sdk (^1.30.0) | You turn on MCP (step 13). |

npm install firebase-admin @modelcontextprotocol/sdk   # whichever of the two you need

While the package is on 0.x the minor version is where breaking changes go. Pin the minor (for example ~0.17.0) if you want an upgrade to be a decision rather than an accident.

If you deploy to Vercel with firebase-admin, add this override now. firebase-admin pulls in jwks-rsa, whose jose dependency is ESM only, and sign-in fails with a 503 on the first request without it (see Troubleshooting below).

{ "overrides": { "jwks-rsa": { "jose": "^5" } } }

With pnpm the same override is "pnpm": { "overrides": { "jwks-rsa>jose": "^5" } }. Then reinstall.

5. Run npx nonext-husk init

npx nonext-husk init                # asks each question below
npx nonext-husk init --yes --project-id acme-site   # takes every default, asks nothing
npx nonext-husk init --dry-run      # lists every file it would write, writes none

| Flag | Default | What it changes | |---|---|---| | --project-id <id> | husk-dev-project | .firebaserc, .env.local, every generated Firebase handle | | --storage-bucket <bucket> | <project-id>.firebasestorage.app | Where media bytes go | | --admin-route <path> | /admin | Where the admin mounts, and every link inside it | | --content-language <tag> | en | The language entries are written in (BCP 47, e.g. de); sets lang and spellcheck on text inputs | | --no-media | media on | Leaves out the media library, its route and storage.rules | | --no-auth | sign-in on | Leaves out the sign-in screen and the session route | | --no-emulators | emulators on | Targets a real Firebase project (step 2) instead of the emulators | | --no-maintenance-mode | on | Leaves out proxy.ts (middleware.ts before Next 16), lib/husk/maintenance.ts and the app/(maintenance) page (step 11) | | --mcp | off | Adds the MCP routes, the key page and the sidebar item (step 13) | | --scheduled | off | Adds the scheduled publisher route, lib/husk/scheduled.ts, a vercel.json cron entry and its indexes | | --preview | off | Adds the preview route and lib/husk/preview.ts, the draft-aware site reader behind the Preview button | | --seo | off | Adds app/sitemap.ts and app/robots.ts (needs Site URL in Settings, General) | | --yes, -y | asks | Takes every default and asks nothing | | --dry-run | off | Lists every file it would write and writes none | | --force | off | Overwrites generated files that exist and differ, writing the old text to <file>.bak first; without it init stops and prints the diff. Files that are only created when absent (listed below) are never overwritten | | --cwd <dir> | current directory | The project to scaffold |

It writes:

  • firestore.rules, storage.rules, firestore.indexes.json, firebase.json, .firebaserc and .env.local at the project root, and cms.config.ts (written only when absent: your content types live there, so init and sync never replace it).
  • The admin under app/: its own root layout, the session guard, one route that serves every content type, the media library, the Schema Builder, the sign-in screen and app/api/husk/session/route.ts.
  • Under lib/husk/, the files only your app can own. Always: firebase-client.ts, session.ts, cms-server.ts, admin-metadata.ts, revalidate.ts and revalidate-client.ts (with app/api/husk/revalidate/route.ts). By default (maintenance mode on) also maintenance.ts. Per option: server-admin.ts with --no-emulators, preview.ts with --preview, scheduled.ts with --scheduled, mcp.ts with --mcp.
  • Your website moves into an app/(site)/ route group, because the admin renders its own <html> and <body> from a second root layout. If app/ already holds route segments of its own, init stops and asks you to move them by hand.

These files are written only when absent, and never replaced or diffed, even with --force, because they may carry things older than Husk or are yours to edit: cms.config.ts, firebase.json, .firebaserc, firestore.indexes.json, .env.local, postcss.config.mjs (only when the project has none), the maintenance page files (app/(maintenance)/layout.tsx and app/(maintenance)/husk-maintenance/page.tsx), vercel.json (--scheduled) and app/sitemap.ts and app/robots.ts (--seo). Everything else it writes is generated: an existing copy that differs stops init until you keep yours, delete it, or pass --force.

6. Set the environment variables

init writes .env.local. What has to be in it, and what has to be in your host's environment, depends on where you run.

| Variable | Where | When | |---|---|---| | NEXT_PUBLIC_FIREBASE_PROJECT_ID | browser and server | Always. | | NEXT_PUBLIC_FIREBASE_API_KEY | browser and server | Real project. The web apiKey from step 2. Not a secret. | | NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET | browser and server | Real project, with the media library. | | FIREBASE_SERVICE_ACCOUNT_KEY | server only | Real project, and MCP. One line of JSON, or base64 of it. Leave unset only where Application Default Credentials exist (Firebase App Hosting, Cloud Run). | | HUSK_FIRST_ADMIN_EMAIL | server only | Real project. The owner's email address, the same one you pass to first-admin. Keep it set on the deployed app: the claims route stays closed without it. | | HUSK_TRUST_PROXY=1 | server | With MCP, on a host with a trusted proxy in front (Vercel). | | HUSK_MCP_DISABLED=1 | server | Switches the MCP endpoint off without a code deploy. | | FIREBASE_AUTH_EMULATOR_HOST, NEXT_PUBLIC_AUTH_EMULATOR_HOST, NEXT_PUBLIC_FIRESTORE_EMULATOR_HOST, NEXT_PUBLIC_STORAGE_EMULATOR_HOST | local | Emulators only. init writes them. Never set these in production. |

Never put the service account key in a variable that starts with NEXT_PUBLIC_: that prefix publishes a variable to every visitor of the site. Without a credential the app fails closed: sign-in cannot get a role and the admin stays locked. On the emulators, FIREBASE_AUTH_EMULATOR_HOST is what lets the server verify tokens; without it every admin route redirects to a sign-in screen that signing in never leaves. That is fail-closed behaviour, not a bug.

7. Deploy the rules and indexes (real project only)

Do this once before anybody signs in, and again after every upgrade and every change to a code-defined content type (see "What to run when" below):

npx firebase deploy --only firestore:rules,firestore:indexes,storage --project acme-site

The rules are the third layer of the permission model: the admin, the SDK and the database all enforce the same table. Without them deployed, the production database is either wide open or closed, depending on its defaults. doctor cannot tell whether they are deployed, only whether the files on disk are current.

8. Create the first admin and sign in

There is no sign-up screen: an admin interface that lets a stranger create an account is not an admin interface. The first account is created by a command, and every account after it by an admin inside the admin.

On the emulators:

npm install --save-dev firebase-tools                        # once; the Firebase CLI
npx firebase emulators:start --only auth,firestore,storage   # terminal 1
npx nonext-husk first-admin [email protected]                   # terminal 2
npm run dev

On a real project, with firebase-admin installed and the project id and FIREBASE_SERVICE_ACCOUNT_KEY in .env.local or .env (the CLI reads both, .env.local first, and a variable already in the shell wins over either; it takes the real-project path only when they hold no emulator host), after step 7:

npx nonext-husk first-admin [email protected]

The password is asked for twice and never echoed. There is deliberately no --password flag, so it cannot land in your shell history; --password-stdin reads it from a pipe. Running the command again for the same account is safe and finishes an interrupted first run. Against a real project it creates the account with a verified email and refuses if the address is already taken by someone else. The account is created for the address you pass; the deployed app must have HUSK_FIRST_ADMIN_EMAIL set to that same address (case does not matter), because the claims route only mints the admin claim for the address that variable names.

Open http://localhost:3000/admin (or your admin route) and sign in. This account is the Owner: it holds every admin right, nobody else can demote, disable or delete it, and only it can hand ownership to another account (Developer, Users, "Make owner").

9. Define content types

Two ways, and they coexist.

In code, in cms.config.ts. They live in git, are reviewed like code, and win a slug conflict with a type of the same slug in the database. Add locked: true to a type your page components depend on and the Schema Builder refuses to touch it.

export const posts = defineContentType({
  slug: "posts",
  kind: "collection",
  label: "Post",
  labelPlural: "Posts",
  titleField: "title",
  fields: {
    title: { type: "text", label: "Title", validation: { required: true } },
    slug: { type: "slug", label: "Slug", from: "title", validation: { unique: true } },
    body: { type: "richtext", label: "Body" },
  },
})

Add it to codeContentTypes and siteContentTypes in the same file.

In the admin, under Developer, Content Types. A type made there exists in Firestore only, gets its sidebar entry and routes at once, and needs no deploy.

Then generate the types, so every read is checked by the compiler, database-defined types included:

npx nonext-husk types            # writes husk-types.d.ts
npx nonext-husk types --check    # exit 1 when the file is missing or out of date (for CI)
npx nonext-husk types --offline  # code-defined types only, no Firebase needed

--offline never drops database-defined types: when the existing file also describes types that are not code-defined, it is left untouched with a warning, under types, sync and upgrade alike. types --offline --force writes the code-defined types only anyway.

Commit the file and name its map when the SDK is created: createServerCMS<HuskContentTypes>({ adapter }). The same file downloads from the admin under Developer, Content Types, "Download types".

10. Read content on the website

Your pages talk to cms, never to Firebase. init generates siteCms() in lib/husk/cms-server.ts:

import { siteCms } from "@/lib/husk/cms-server"

const post = await siteCms().collection("posts").findBySlug(slug) // post.title is a string
const home = await siteCms().single("home").get()
const general = await siteCms().settings().get("general") // GeneralSettings | null

The reader is published-only by construction; a draft cannot leak through it. How long pages may be stale and what invalidates them: docs/caching.md.

Types made in the Schema Builder are readable by the site with no copy step. Every database-defined type is projected to a world-readable publicSchemas/{slug} document (slug, kind, labels, the field list without admin-only help text, placeholders, read-only flags and hidden fields, sort, preview path, SEO flag and schema version; never the permission matrix, the lock, removed fields or who edited). siteCms() reads these lazily, merges them with your code-defined types (code wins a slug) and reuses them for one hour (SCHEMA_REVALIDATE_SECONDS in lib/husk/cms-server.ts). A type with no public schema, or one that is older than its entries, is skipped and reads as empty, never as an error, and only the projected fields reach the page: the stored value of a removed or hidden field does not. count() counts exactly what paging through findMany returns, so it reads the matching entries (one read each) instead of one aggregation for such a type. Published entries are public either way; turn off Public schema in the type's settings (publicSchema: false, also on the MCP create and update tools) to keep the field list out of the public too. nonext-husk sync writes missing or stale public schemas with the Admin SDK and removes ones no type needs (including a document without a slug), and doctor reports a type whose public schema is missing or differs; deploy the current firestore.rules first, or the reader serves the code-defined types alone and logs why.

Keep the admin out of public pages. A public page must not load admin JavaScript. Import nothing from @nonext/husk/admin/ui in a file a public route mounts (Next mounts app/global-error.tsx under every root layout, so that file counts), and give a <Link href="/admin"> in your site prefetch={false}, because Next prefetches the JavaScript of every linked route and the admin is the largest one. nonext-husk doctor flags a global-error.tsx that imports the admin UI, and nonext-husk sync replaces it.

11. Settings and maintenance mode

Settings (sidebar, Settings) hold General (site title, description, meta title, meta description, title prefix, Site URL) and Maintenance mode. They are not content: no drafts, no publish step, they apply as soon as they are saved, and they need the manage-settings permission (admins and editors).

Maintenance mode is a switch with a message, colors and a background image. While it is on, public visitors are rewritten to /husk-maintenance by the generated proxy.ts (middleware.ts on Next 15 and older, chosen from your installed Next version) and see only that page. A signed-in admin browses the real site. If reading the switch fails, the site stays up.

init writes the proxy, lib/husk/maintenance.ts and the maintenance page itself: app/(maintenance)/layout.tsx and app/(maintenance)/husk-maintenance/page.tsx. The page lives in its own route group with its own root layout, so no header, footer or navigation can appear around it. Both files are written only when absent, so you can restyle them (add a font or stylesheet to the layout) and a later init will not overwrite your changes. With --no-maintenance-mode, none of this is generated.

The proxy and lib/husk/maintenance.ts import only @nonext/husk/edge (web-standard APIs, no Firebase SDK), so they add almost nothing to the bundle that runs before every request. The read gives up after 1.5 seconds and reads as "off", it skips requests carrying the next-router-prefetch header (a prefetch during maintenance can return the real page's prefetch payload, which is public content; visitors see the maintenance page on navigation), robots.txt and sitemap.xml, and nonext-husk sync moves an unedited middleware.ts to proxy.ts on Next 16 and lists an edited one (migrate it with npx @next/codemod@canary middleware-to-proxy ., and import from @nonext/husk/edge instead of @nonext/husk/admin and @nonext/husk/firebase).

To show admins a ribbon on the real site, render MaintenanceRibbon from @nonext/husk/maintenance in your site layout when maintenance mode is on and the visitor has an admin session. Read the switch uncached for that check (fetchMaintenanceEnabled, imported from @nonext/husk/edge, with revalidateSeconds: 0), so the ribbon never disagrees with the switch in Settings.

12. Users, roles and the media library

  • Users (Developer, Users): an admin creates an account and types or generates its password, copying it before closing the dialog, since it is never shown again (there is no invite email). The same screen resets a password, disables or deletes an account. Roles are admin, editor, author and contributor, with a per-content-type permission matrix on top (read, create, update, delete and publish). The last enabled admin cannot be removed.
  • Media (sidebar, Media): uploads, usage tracking and an "Unused only" filter. The image and file fields in entry forms open a picker that also uploads, by button or drag and drop.
  • Sessions refresh while you work; an unremembered one ends after 12 hours without activity. Remember me on the sign-in screen keeps a session for at least 7 days, and signs you back in without a prompt when you return after the hour its token lasts.

13. MCP for AI agents (optional)

MCP lets an AI agent such as Claude read and edit content and media with an API key an admin controls. It is off unless you ask for it.

  1. npx nonext-husk init --mcp, or add it to an existing project by running init --mcp again. It adds /api/husk/mcp, /api/husk/mcp-keys, lib/husk/mcp.ts and the MCP Integration page under Developer.
  2. npm install firebase-admin @modelcontextprotocol/sdk.
  3. Redeploy the rules and indexes (step 7). The endpoint refuses to run against rules from before MCP. Required on a real project with media: the Cloud Storage service agent (service-<project number>@gcp-sa-firebasestorage.iam.gserviceaccount.com) must hold the role Firebase Rules Firestore Service Agent (roles/firebaserules.firestoreServiceAgent), because storage.rules reads the MCP key from Firestore. Without it every media upload over MCP fails with "Storage refused the upload (storage/unauthorized)". nonext-husk doctor reports whether it is held (or that it could not read the IAM policy), and sync --deploy offers to grant it after the deploy, asking first; a run without a terminal only prints the command. By hand: gcloud projects add-iam-policy-binding <project id> --member="serviceAccount:service-<project number>@gcp-sa-firebasestorage.iam.gserviceaccount.com" --role="roles/firebaserules.firestoreServiceAgent", or in the console under IAM, tick "Include Google-provided role grants", and add the role to that account. It takes a few minutes to apply.
  4. Set FIREBASE_SERVICE_ACCOUNT_KEY, and HUSK_TRUST_PROXY=1 on Vercel.
  5. Optional: in the Firestore console add a TTL policy on expireAt for the collection group mcpAudit and for the rate limit counters, so they expire after 90 days.
  6. In the admin, open Developer, MCP Integration and create a key: a name, a role (contributor, author or editor), the content types it may use, drafts-only (on by default), deleting (off by default) and an expiry. The key is shown once.
  7. Connect a client:
claude mcp add --transport http husk-your-site-example https://your-site.example/api/husk/mcp \
  --header "Authorization: Bearer husk_mcp_..."

The server name is site-specific (husk- plus the site host, dots as dashes; husk-localhost-3100 for a local site) so two sites do not overwrite each other in one client. The key dialog shows the exact command for your site.

HUSK_MCP_DISABLED=1 switches the endpoint off from the environment. The MCP Integration page also has an "Agents can connect" switch that stops every key at once with no deploy. It is stored in Firestore: the route refuses while it is off (and refuses when the switch cannot be read), and the rules reject agent writes too, so the rules layer needs the current firestore.rules deployed. To change a key's role, use "Change role" in the key's menu instead of creating a new key: the key, its limits and its history stay. The new role applies from the agent's next request; a request already running when you change it finishes under the old role, and the rules refuse a session minted under the old role, so that one in-flight request is the whole window. What a key can do, and how it is contained, is described in "MCP integration" below.

Developer keys (optional, off by default). A content key edits entries and media. A developer key is an editor key that can also create and update database-defined content types and read and write the General and Maintenance settings, so a coding agent can stand up a site instead of only filling one in. Turn it on in lib/husk/mcp.ts by setting DEVELOPER_KEYS = true and deploying; that is a decision in git, not a click. Then create a key with Access set to Developer. Developer keys are editors over every content type, expire in 7 days by default (30 at most, never "never"), and stay under the "Agents can connect" switch. They cannot delete a content type, touch a code-defined or locked one, change permissions or the lock flag, or reach users, keys, the audit log or the ownership marker; the rules refuse each of those, not only the tools. A type a key creates gets a public schema like any other; the publicSchema: false argument keeps it out, but a key cannot switch an already public type off (that deletes a document). Every tool result that changes a content type carries syncRequired: true and the command npx nonext-husk sync --deploy: the live schema changed, but husk-types.d.ts and the Firestore indexes in your checkout are stale until you run it. Existing keys are content keys; to give an agent more authority, create a new key.

14. Deploy the site

On Vercel (other hosts are the same in principle):

  1. Push the project and import it.
  2. Set the environment variables from step 6 in the project settings. The NEXT_PUBLIC_ ones are read at build time, so set them before the first build. FIREBASE_SERVICE_ACCOUNT_KEY is the JSON on one line, or base64 of it. Set HUSK_FIRST_ADMIN_EMAIL, and HUSK_TRUST_PROXY=1 if you use MCP. Set none of the emulator variables.
  3. Make sure the jose override from step 4 is in package.json.
  4. Deploy the rules and indexes (step 7) before the first visit, if you have not already.
  5. Create the first admin (step 8) from your machine, with the same project id, service account key and owner email in .env.local. It writes to the production project.
  6. Open /admin on the deployed site and sign in.

15. Check the installation

npx nonext-husk doctor

It exits non-zero on a problem, so it works as a CI step. It proves from the files that cms.config.ts loads, the installed package matches the scaffold, the rules files came from the installed package, every composite index and field override is present in firestore.indexes.json, the types file is current, and (with MCP) the peers, routes and audit indexes are there. It reports as unchecked, with the reason, what only the real project can confirm: whether the rules and indexes are deployed, whether Storage and Authentication are switched on, and whether the service account has the permissions it needs.

Going live from an emulator project

A project created with the default init targets the emulators. To point it at a real Firebase project (step 2), install firebase-admin (step 4) and scaffold again:

npx nonext-husk init --no-emulators --project-id acme-site --force

Repeat the feature flags you scaffolded with (--mcp, --scheduled, --preview, --seo, --no-media and so on): init does not read them back from cms.config.ts. --force replaces every generated file that exists and differs, and writes the old text to <file>.bak first, so look through the .bak files for edits you want back. It adds lib/husk/server-admin.ts and app/api/husk/claims/route.ts. It does not touch the files that are only created when absent, cms.config.ts (your content types) among them, so finish by hand:

  • cms.config.ts: in huskConfig, set emulators: false and the real projectId and storageBucket.
  • .firebaserc: the real project id as the default project.
  • .env.local: delete the emulator block and set the real-project values from step 6. While an emulator host is still in that file, first-admin and types talk to the emulators.

Then continue with steps 7, 14 and 8.

What to run when

| When | Do | |---|---| | You add or change a code-defined content type | npx nonext-husk sync (rules, indexes, types, check), commit, and npx nonext-husk sync --deploy when the indexes changed. | | Someone creates or edits a type in the Schema Builder | npx nonext-husk sync and commit, so the compiler knows the new fields and the indexes cover them. The site reads the type on its own through its public schema (within an hour); sync also repairs a missing one. | | You change settings or maintenance mode | Nothing. They apply when saved. | | You upgrade @nonext/husk | Read the changelog, then npx nonext-husk sync --deploy, and review any *.rules.new and *.new it wrote. Run npx nonext-husk init only if the changelog says generated files changed (it shows a diff for each; keep yours, or take Husk's with --force). | | You turn on MCP | Steps 13.1 to 13.6: init --mcp, install the peers, redeploy the rules, set the variables. | | You go live | Steps 2, 4 and 6, "Going live from an emulator project" below, then steps 7, 14 and 8, in that order. | | You want a Preview button | Set previewPath on the type, run npx nonext-husk init --preview, read drafts in your pages through siteReader(). | | You want SEO (sitemap, robots, per-page metadata) | Turn on SEO for the type, set Site URL in Settings, run npx nonext-husk init --seo, call seoMetadata() in generateMetadata. doctor checks the files. | | You want scheduled publishing | npx nonext-husk init --scheduled, set CRON_SECRET on the host, deploy firestore.indexes.json. On Vercel Hobby, change the cron in vercel.json to once a day (for example 0 6 * * *). | | You want a backup | npx nonext-husk export (add --media for the files), or Export content under Developer, System in the admin. Restore with npx nonext-husk import <file> --apply (add --schemas --settings --media for the rest; media files are not restored). | | Indexes may be missing | npx nonext-husk indexes (--check in CI), then npx firebase deploy --only firestore:indexes. | | You want to know whether a newer release exists | npx nonext-husk upgrade (the same as sync, after looking it up). | | A new person needs access | An admin creates the account in Developer, Users. There is no sign-up. |

Known issues

Open defects in the current release (0.18.2; the .env loading and duplicate Tiptap issues of 0.18.1 are fixed), with the workaround until a fix ships. The same list is on the product site at https://husk.nonext.at/docs/known-issues (Markdown: /docs/known-issues.md).

| Issue | Symptom | Workaround | | --- | --- | --- | | init --no-emulators does not write the jose override | On a real Firebase project every Admin SDK route (sign-in claims, MCP, preview, scheduled publishing) answers 503, and the server log says firebase-admin/auth failed to load with ERR_REQUIRE_ESM. Since 0.18.2 init names the override in its next steps and doctor fails when it is missing. | Add the override from step 4 of the install yourself, then reinstall: npm "overrides": { "jwks-rsa": { "jose": "^5" } }, pnpm "pnpm": { "overrides": { "jwks-rsa>jose": "^5" } }. |

Troubleshooting

| You see | Cause | Fix | |---|---|---| | "This needs a database index that is missing" | A query needs a composite index that is not deployed. The media library's type filter needs the media index, and its "Unused only" filter needs the mediaRefs field override. | npx nonext-husk indexes adds every missing index and field override to firestore.indexes.json (npx nonext-husk doctor names what is missing), then npx firebase deploy --only firestore:indexes. New indexes take a minute or two to build. | | Sign-in always answers 503 and the log says "the Admin SDK could not reach Google or lacks a permission it needs" although the network and credential are fine | firebase-admin loads jwks-rsa, whose jose is ESM only, and require() throws ERR_REQUIRE_ESM. Common on Vercel. | Add the jose override from step 4 and reinstall. The package's own error message names this cause and points at the same fix. | | Every admin route redirects to sign-in and signing in never leaves it | On the emulators: FIREBASE_AUTH_EMULATOR_HOST is missing, so the verifier trusts nothing. On a real project: no service account credential, or HUSK_FIRST_ADMIN_EMAIL unset. | Set the variable from step 6. npx nonext-husk doctor checks it. | | Sign-in works but reads or writes are refused | The deployed rules are older than the package, or were never deployed. | Redeploy the rules (step 7). doctor flags a rules file that is older than the installed package, but not what is deployed. | | MCP refuses to serve, or answers 503 "switched off" | The project's rules predate MCP, HUSK_MCP_DISABLED=1 is set, or an admin turned off "Agents can connect" on the MCP Integration page. | Redeploy the rules; check the variable and the switch. | | Turning maintenance mode on shows a 404 to visitors | The app/(maintenance)/husk-maintenance route is missing (deleted, or the project was created with --no-maintenance-mode). | Restore the two files. npx nonext-husk init writes them when they are absent. | | pnpm test:rules or the emulators fail with connection refused, or a port is in use | The emulator ports (Firestore 8085, Auth 9099, Storage 9199, UI 4000, hub 4400) are host-wide, so two runs at once collide. | Stop the other run, or free the ports, and start again. | | init stops with a list of files and a diff | A generated file it owns exists and differs. Nothing was written. | Keep yours, delete the file to take Husk's, or run with --force for all of them (each old text is kept as <file>.bak). |

The longer reference for init, doctor and the real-project details is docs/getting-started.md. It is tested against the emulators, and one production project runs Husk on a real Firebase project (husk-demo-site, which serves the Husk product site); doctor reports what it cannot check from files rather than passing it.

Entry points

| Import | What it is | |---|---| | @nonext/husk | Schema, registry, validation, permissions, types. No React, no Next, no Firebase. | | @nonext/husk/core | A subset of the root: the registry, SDK, SEO and settings helpers and errors, without the permissions, schema and types modules, which have their own entry points. | | @nonext/husk/types | Types only. | | @nonext/husk/schema | Validation and the field-type registry. | | @nonext/husk/permissions | can(), the one permission truth table. | | @nonext/husk/server | The read SDK for server code. Preview lives here. | | @nonext/husk/client | The read SDK for browser code. No preview, by construction. | | @nonext/husk/richtext | Rendering stored rich text. Separate so a project without a rich text field does not carry Tiptap. | | @nonext/husk/maintenance | The maintenance page, the admin ribbon and the preview banner. | | @nonext/husk/mcp | The MCP server and key routes for AI agents. Needs the optional @modelcontextprotocol/sdk peer. | | @nonext/husk/admin | The server half of the admin: session, guard, route handlers, navigation as data. | | @nonext/husk/admin/ui | The client half: every React component the admin renders. | | @nonext/husk/auth | Sign-in, sign-up, roles, claims. | | @nonext/husk/edge | Web-standard APIs only, for proxy.ts and middleware.ts: fetchMaintenanceEnabled, hasPlausibleSession, resolveMaintenanceAccess, SESSION_COOKIE. No Firebase SDK, React or Node built-ins. | | @nonext/husk/firebase | The wiring: adapter, auth and upload factories. Not the Firestore layout. | | @nonext/husk/firebase/admin | Server only: the Admin SDK verifier, claim writer and claim route. Needs the optional firebase-admin peer. | | @nonext/husk/admin.css | The admin stylesheet. Import once. |

@nonext/husk/admin and @nonext/husk/admin/ui are separate on purpose, and merging them back would break every admin route. A Next server component imports the first and gets no React hook in its module graph.

packages/cms/api/public-api.md, shipped in this package as api/public-api.md, is the reviewed list of every exported name with its type.

Reading content

init generates lib/husk/cms-server.ts with siteCms(), the reader every public page uses. It builds the adapter, the registry and the anonymous connection for you, so a page only reads:

import { siteCms } from "@/lib/husk/cms-server"

const page = await siteCms().collection("projects").findMany({ limit: 12 })
const project = await siteCms().collection("projects").findBySlug(slug) // project.title is string
const home = await siteCms().single("home").get() // typed fields, no casts
const general = await siteCms().settings().get("general") // GeneralSettings | null
const cover = await siteCms().media().getById(project.cover.id)

Every slug, field and settings key is checked by the compiler. siteCms() is typed from siteContentTypes in cms.config.ts, so code-defined types are inferred without a generated file; content types created in the admin Schema Builder are typed through the generated husk-types.d.ts (see "Typed content" below).

siteCms() is built with createServerCMS from @nonext/husk/server, which needs an adapter made with createFirestoreAdapter from @nonext/husk/firebase together with a registry and a current-user function. Build one yourself only for a second reader; the generated file is the worked example.

findMany returns 25 entries by default and at most 100 (limit); pass page.cursor back as cursor for the next page, with the same sort (the cursor is opaque and stays valid when an entry on a page is unpublished meanwhile; one made under another sort is refused with a SchemaError rather than read as the first page). A content type's defaultSort orders these published reads; the admin list is sorted by the last update unless the editor picks a column. Reading a draft by id (findById, or a singleton that is still a draft) returns null rather than throwing.

The reader is published-only. CmsQuery has no status, so a draft cannot leak through a forwarded query object or a URL parameter. Drafts are read through siteReader() from lib/husk/preview.ts (init --preview), which returns a signed-in reader while draft mode is on and the published-only reader for everyone else.

Reserved keys and conflicting writes

The entry metadata owns these keys, so a content type cannot use one as a field key: id, status, createdAt, createdBy, updatedAt, updatedBy, publishedAt, schemaVersion, publishAt, unpublishAt, mediaRefs, rev, inReview, reviewRequestedBy and reviewRequestedAt (exported as ENTRY_META_KEYS from @nonext/husk/types). A code-defined type that uses one throws at defineContentType; a Schema Builder type lands in the invalid list. nonext-husk doctor (and so sync and upgrade) reports a clash; the fix is to rename the field and move its stored values.

Every entry carries rev, a counter that every writer bumps. An admin read has it; a public read does not. An update that passes expectedRev (and expectedUpdatedAt) from the entry it read is refused with an EntryConflictError when the entry was saved since, and nothing is written. The admin editor, bulk actions, revision restore and the MCP update and publish tools do this; a call that names no expectedRev overwrites, as before.

Typed content

Everything the SDK returns is typed. A code-defined content type is typed by the compiler. A type created in the admin Schema Builder exists only in Firestore, so generate its types:

npx nonext-husk types            # writes husk-types.d.ts: code and database-defined types
npx nonext-husk types --check    # exit 1 when the file is missing or out of date (for CI)
npx nonext-husk types --offline  # code-defined types only, no Firebase needed

Commit the file and pass its map when the SDK is created:

import type { HuskContentTypes } from "@/husk-types"

const cms = createServerCMS<HuskContentTypes>({ adapter })
const post = await cms.collection("posts").findBySlug(slug) // post.title is a string

The same file downloads from the admin under Developer, Content Types, "Download types". It describes the schema at the moment it was generated, so generate it again after a field changes. npx nonext-husk doctor flags a file that is stale against the code-defined types, and types --check against the database as well.

The built-in settings documents are typed by key:

const general = await cms.settings().get("general") // GeneralSettings | null
const maintenance = await cms.settings().get("maintenance") // MaintenanceSettings | null

Any other key returns a loose record.

MCP integration

Let an AI agent, such as Claude, work on content and media in a live project, with API keys an admin controls. It is off by default: npx nonext-husk init --mcp adds the route (/api/husk/mcp), the key route, lib/husk/mcp.ts and an MCP Integration page under Developer. Install the two optional peers it needs, firebase-admin and @modelcontextprotocol/sdk, and deploy the current firestore.rules and storage.rules. On a real project the Cloud Storage service agent also needs the role Firebase Rules Firestore Service Agent (roles/firebaserules.firestoreServiceAgent), or media uploads over MCP fail with "Storage refused the upload (storage/unauthorized)". nonext-husk doctor reports whether the agent holds it (or that it could not read the IAM policy), and sync --deploy offers to grant it after the deploy, asking first and never without an answer at the prompt; a run without a terminal prints the gcloud command instead. The manual command is in the setup steps above.

On the MCP Integration page an admin creates a key: a name, a role (contributor, author or editor), the content types it may use (or all of them, including ones created later), whether it is drafts-only (on by default: the agent proposes, a person publishes), whether it may delete (off by default), and an expiry. The key is shown once. Then connect a client:

claude mcp add --transport http husk-your-site-example https://your-site.example/api/husk/mcp \
  --header "Authorization: Bearer husk_mcp_..."

The server name is site-specific (husk- plus the site host, dots as dashes; husk-localhost-3100 for a local site) so two sites do not overwrite each other in one client. The key dialog shows the exact command for your site.

What an agent can do is exactly the list of tools its key gets, and nothing else exists: list and describe content types (read from the project on every request, so a type created in the Schema Builder is usable at once, with no rebuild), list, read, create, update and delete entries, publish and unpublish (not on a drafts-only key), and list, upload and delete media (upload from base64 or a public URL, with the same limits as the media page; delete only for an editor key with deletion allowed). A content key never reaches content types or settings, and no key reaches users or keys. Developer keys (off until DEVELOPER_KEYS is set in lib/husk/mcp.ts) can also create and change database-defined content types and the General and Maintenance settings.

Writes are checked like the admin's: every create and update runs the same field rules the entry form and the database write use (a malformed date or a blank relation id is refused with its field path), and answers invalid with the field errors, never a stored bad value. On a content type with SEO turned on, husk_describe_content_type lists the seo group (title, description, share image, canonical URL, noindex) and create and update accept a seo object, validated like the SEO panel with error paths such as seo.canonical; an update merges the keys it sends into the stored group, and null clears it.

How it stays inside its key. Each key signs in as its own service user, so firestore.rules and storage.rules judge every call, not only the package: a revoked or expired key stops at once, and the type list, drafts-only and the delete switch are enforced by the database too. Keys are stored as a hash; failures answer a uniform 401 and lock a key after repeated wrong secrets; each key is rate limited; every request is written to an activity log that holds the tool, the target and the outcome, never the content. The activity log filters by key and outcome together, which needs the third mcpAudit index: run npx nonext-husk indexes and redeploy the indexes when upgrading. A project on older rules refuses to serve MCP rather than serve it with weaker checks.

Things to know: base64 uploads are limited to about 3 MB on Vercel (use a URL for larger files); set HUSK_TRUST_PROXY=1 on a host with a trusted proxy in front so failed attempts are counted per caller; add a Firestore TTL policy on expireAt for mcpAudit and the counters to expire them; HUSK_MCP_DISABLED=1 or the "Agents can connect" switch on the MCP Integration page switches the endpoint off (the page's switch is also enforced by the rules, so it needs the current rules). A key's role changes in place with "Change role" and applies from the agent's next request; one request already running finishes under the old role. The project owner (the account settings/bootstrap names, not any admin) can remove a revoked or expired key with "Remove" in its menu: an expired key is revoked in the same step, the key leaves the list for a collapsed "Removed keys" section with the date and who removed it, it can never connect again (the rules refuse it too, so deploy the current firestore.rules and storage.rules), and its activity stays in the log with a "Removed" label. It is a soft delete; nothing is purged. A project created before removal existed adds two props to its app/(husk)/admin/(shell)/developer/mcp/mcp-panel.tsx: keep viewerIsOwner from the list response in state and pass viewerIsOwner={viewerIsOwner}, and pass onRemoveKey={async (keyId) => { await keyRequest({ action: "remove", keyId }) }}; without them the page offers no Remove. An agent that reads content can be misled by what that content says; the mitigations are the ones above (drafts-only, a type list, no deleting, short expiry, the activity log), not something the server can detect.

Editor features

  • A publish refreshes your site. After a save, status change or delete in the admin, the generated admin posts the entry's cache tags to app/api/husk/revalidate/route.ts (session-guarded, Husk tags only); the scheduled publisher and the MCP route clear the same tags in process. Pages cached for an hour with unstable_cache and Husk's tags therefore update at once. Writes made outside Husk wait for the hour: docs/caching.md.
  • Preview. Set a previewPath on a content type (in code, or under Developer, Content Types), for example /projects/{slug}. {id}, {slug} and any field key fill in from the entry. The entry editor then shows an Open preview button that opens your website on the saved entry, drafts included. Scaffold it with npx nonext-husk init --preview: it adds app/api/husk/preview/route.ts (checks the admin session, then turns Next.js draft mode on) and lib/husk/preview.ts, whose siteReader() returns a reader signed in as the editor while draft mode is on and the normal published-only reader for everyone else. A page shows drafts only when it reads through siteReader(). Render <PreviewBanner /> from @nonext/husk/maintenance in your layout while isPreviewing() is true; that function is generated in lib/husk/preview.ts. Needs firebase-admin.
  • Scheduled publishing. The entry editor has a Schedule panel: a publish time for a draft and an archive time for a live entry, in your time zone. A draft with a publish time shows as Scheduled in lists, and a new entry with a future publish time is saved with Schedule instead of Publish. Nothing changes on the public site until the scheduler route runs, so scaffold it with npx nonext-husk init --scheduled: it adds app/api/husk/scheduled/route.ts, a vercel.json cron entry (hourly) and the indexes it needs. Set CRON_SECRET (Vercel Cron sends it as a bearer token) or HUSK_CRON_SECRET on the host; without one the route answers 503. Vercel Hobby plans allow one cron run a day and reject a finer schedule, so on Hobby change the cron in vercel.json from hourly to once a day, for example 0 6 * * *.
  • SEO. Set SEO on a content type (seo: true in code, or the switch beside Preview path under Developer, Content Types) and its entries get an SEO panel: title, description, share image, canonical URL and "hide from search engines". The values are stored under one reserved key, seo, so turning it on is refused while the type has a field, or a removed field, with that name; it is off by default and no upgrade turns it on. Set Site URL in Settings, General (an origin such as https://example.com), because canonical URLs and the sitemap need one. npx nonext-husk init --seo writes app/sitemap.ts (cached, published entries of every code-defined collection with a previewPath, minus noindex, at most ten pages of a hundred per type) and app/robots.ts. In a page, seoMetadata({ entry, type, settings }) from @nonext/husk/core returns what Next's generateMetadata takes. For structured data, serializeJsonLd(data) (also from @nonext/husk/core and the package root, no React) returns a JSON string that is safe inside an inline <script type="application/ld+json">: <, >, &, U+2028 and U+2029 are escaped, so a CMS string such as </script> cannot close the tag, and JSON.parse returns the original data. Put it in dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}.
  • History. Every save keeps a snapshot, the last 20 per entry. The entry editor's History panel shows what changed and restores a version (a restore is a new save, and keeps the entry's status).
  • Publishing and review. Making an entry live, scheduling it, and saving an entry that is already live need the publish permission; taking an entry down needs only update. New content types (the init starter and the Schema Builder) start with publish: ["admin", "editor"], so contributors and authors save drafts and press Ready for review; a type with no publish entry behaves as before (every role, an author on their own entries). On a restricted type an author can no longer edit their own live entry, but can unpublish it and edit the draft. The list has a Ready for review view (drafts only, newest edit first) and the dashboard counts them. Publishing or archiving clears the flag. Types created by an agent over MCP stay unrestricted for publish until an admin narrows them in the Schema Builder. Deploy the rules before the app: they accept a matrix with publish, which older rules refuse. The scheduled publisher and an Admin SDK restore are trusted writers and do not ask for publish again. Refreshing media links rewrites live entries, so it needs publish and reports the entries it could not change.
  • Bulk actions. Select rows in a list, then Publish, Move to draft, Archive or Delete. Each row goes through the same permission and validation checks as a single save, and rows that could not change are named afterwards.
  • Export and import. Developer, System, Export content (administrators; on a project generated before the System page it is still on Content Types until you run init again) downloads one JSON file with every entry (drafts included), the media list and the settings. npx nonext-husk export [--out file] [--media] does the same from the command line, and --media also downloads the media files. npx nonext-husk import <file> restores entries and is a dry run until you add --apply (--overwrite replaces what already exists). --schemas also restores the content types made in the Schema Builder (never a code-defined or locked one, and never one you deleted unless you add --revive-types), --settings the General and Maintenance settings, and --media the media records. The media files themselves are not restored: a record may point at a file the target bucket does not have, so re-upload them from the export --media folder. User accounts, content types made in code, rules, MCP keys and revision history are not in a backup.
  • nonext-husk indexes writes the Firestore indexes your content types need into firestore.indexes.json (code and Schema Builder types; --check for CI, --offline for code types only). nonext-husk upgrade looks for a newer release and then brings a project up to the installed version: version, rules (a stale file is written as <file>.new for review; --force replaces it), indexes, types, doctor, and the deploy command (--deploy runs it). nonext-husk sync is the same reconcile without the version lookup, and is the one command to remember after any schema change (--deploy ships it). It also replaces every generated source file you have not edited: each carries a stamp comment with a hash of its generated text (whitespace ignored, so other formatting such as added semicolons counts as an edit), and a file that no longer matches its stamp is left alone with the current template written beside it as <file>.new (doctor lists such files as a note). Files from before stamps are listed the same way once; an unedited old admin shell without the Ctrl+K command palette and an old global-error.tsx are still rewritten. sync and upgrade take --dry-run (as do types and indexes): every step runs against a recording file system, the files a real run would write are listed with a line count, nothing is written and nothing is deployed. Existing firestore.indexes.json entries that end in a redundant __name__ are rewritten to the form firebase deploy accepts (indexes does the same). sync also writes the public schema of every Schema Builder type that lacks a current one (Admin SDK; --dry-run only reports it), and nonext-husk doctor reads the Schema Builder types by default and reports a missing or differing public schema; --offline skips that.

What the admin covers

  • Content types (code and Schema Builder), list views, forms, drafts and publishing.
  • A media library with usage tracking, and an image picker in entry forms that uploads a file (button or drag and drop) and selects it in one step.
  • Settings: General (site title, description, meta fields, title prefix, Site URL) and Maintenance mode (switch, message, colors, background image). Settings are not content: no drafts, and they are gated by their own manage-settings permission.
  • Maintenance mode: while on, public visitors get only the maintenance page. Signed-in admins see the real site with a ribbon. npx nonext-husk init generates it by default (maintenanceMode in huskConfig), including the root proxy.ts (middleware.ts before Next 16) that reads the switch.
  • Users: create with a password, reset, disable, delete, change your own. Roles admin, editor, author and contributor, plus a per-type permission matrix. The project owner (the first admin, shown as "Owner") cannot lose their rights to anyone else and can only hand ownership to another account themselves, from the Users screen. The last enabled admin cannot be removed.
  • Sessions: refreshed while you work (unremembered ones end after 12 idle hours); "remember me" keeps a session for at least 7 days, and signs you back in without a prompt when you return after the hour its token lasts.
  • MCP Integration: API keys for AI agents, with a role, a type list, drafts-only and an activity log.
  • A dashboard, a "View site" link, and the installed version in the sidebar.

Security notes

  • The admin shares the public site's origin. Firebase keeps the signed-in user in same-origin browser storage, and the admin session cookie is refreshed from it. A cross-site scripting hole on any public page of the site can therefore reach the admin session, so keep the site's own code and third-party scripts free of it. Sessions refresh for as long as an editor works: an unremembered session stops refreshing after 12 hours without a pointer, key, wheel, touch or scroll event in an admin tab (then the cookie runs out with its token and the next navigation lands on the sign-in screen), a remembered session lasts at least 7 days. The idle limit bounds a forgotten tab; it does not remove the shared-origin exposure. Husk adds no security headers and no separate admin origin.

Versioning

0.x: the minor version is where breaking changes go, and a release note that describes one says so in its first line. See CHANGELOG.md.

License

PolyForm Shield License 1.0.0. Use it for anything, including running commercial customer sites on it. It rules out building a product that competes with Husk itself.