API

Every endpoint below is shown at the default apiPath of /api/blog. If you scaffolded with --api-path /api/articles, substitute that prefix throughout.

Authentication

Send the key as a Bearer token:

curl -X POST http://localhost:3000/api/blog/posts \
  -H "Authorization: Bearer $BLOGPANEL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"title":"My Post","status":"published"}'

Two kinds of credential are accepted:

  • The master key, BLOGPANEL_API_KEY. Always treated as admin and bypasses every permission check — the break-glass credential. There is no way to scope it down.
  • Generated tokens, created via POST /tokens. Each one carries a role (admin, editor or author, chosen at creation) that decides what it may do — see Roles and permissions below. Useful for CI, n8n, and other external integrations, and revocable without rotating the master key.

Generated tokens are issued with an nbk_ prefix — legacy naming from the package's previous identity, kept so that tokens issued before 1.0 keep validating. Only the SHA-256 hash is stored; the plaintext is shown once at creation and never again.

Missing header returns 401 UNAUTHORIZED. Wrong credential returns 403 FORBIDDEN. An authenticated principal whose role does not grant the action it attempted also returns 403 FORBIDDEN, naming the principal and the action in the message body.

What is public

Endpoint Auth
GET /posts Public
GET /categories Public
GET /sitemap.xml Public
GET /rss.xml Public
GET /media/file/[...key] Public — images referenced from published posts
Everything else Bearer token

Note that GET /media — the media listing — requires authentication, unlike the file streaming route.

"Bearer token" means any valid credential is enough to authenticate — it does not mean any valid credential is enough to succeed. Several of those routes additionally require a specific role grant (GET /tokens and GET /audit, for example, both require admin); see Roles and permissions.

Roles and permissions

Every generated token has a role: admin, editor or author. The master key is always admin and bypasses every check described here.

Action admin editor author
posts:create
posts:update:any
posts:delete
posts:publish
categories:write
media:upload
media:delete
settings:write
tokens:write
audit:read
redirects:write

This is the exact GRANTS table in src/lib/permissions.ts — the single source of truth. An action absent from a role's row is refused for that role; there is no implicit fallback. In short:

  • admin — everything.
  • editor — everything except settings, tokens and the audit log. Can create, update, delete and publish any post, own or not, and can create and delete redirects.
  • author — can create posts, upload media, and publish (see the note on posts:publish below). Cannot delete anything, cannot touch categories, media belonging to others, settings, tokens, the audit log or redirects. Can only update posts they own — see Ownership below.

posts:publish is not gated by a separate endpoint — there is no PATCH /posts/:id/publish route. Publishing a post means setting status: "published" through POST /posts (gated on posts:create) or PUT /posts?id= (gated on posts:update:any, with the ownership fallback below for authors). The grant exists so a future dedicated publish action has somewhere to plug in without a matrix change.

No tokens:read — listing tokens requires tokens:write

GET /tokens is gated on tokens:write, not a read-only action — there is no tokens:read in the permission table. This is deliberate: the response includes every token's name, prefix and role, and listing tokens is treated as admin business rather than a lesser, readable-by-more-roles action. Adding a fourth token action for one read route was judged not worth it; the write grant doubles as the read gate. Practically, this means editor and author tokens cannot see the token list at all, only admin tokens and the master key can.

Ownership

Posts have an ownerId — the id of the token (or "master", for the master key) that created them. It is set once, from the authenticated principal, and only createPost ever writes it:

  • It is never taken from request input. POST /posts and PUT /posts both ignore an ownerId field in the body — sending one is an impersonation attempt, not a way to reassign a post.
  • It is never changed by an update. Editing a post — by anyone, including an admin — does not change who owns it.
  • Posts created before 1.2.0 have no ownerId at all. This is the common case for every existing install: every post you had before upgrading is a legacy post. A legacy post is editable only by editor and admin roles (or the master key) — never by an author, regardless of who wrote it originally. There is no automatic way to assign an owner to a legacy post after the fact; updatePost will not set ownerId even when an editor or admin saves it. If you want a specific author-role token to own a pre-1.2.0 post, set ownerId on that document directly in MongoDB, to the token's _id as a string.
  • Posts created by the master key are owned by "master". No author-role token can ever match that id, so these are effectively editor/admin-only too.
  • Pipeline-created posts are owned by the pipeline's token. If an external integration (ButterBlogs, an n8n workflow, a CI job) creates posts through its own API token, that token is the owner. A human who then needs to edit that post through the admin panel needs the editor role (or admin), not author — an author token, even the pipeline's own if it somehow had one, only owns what it created.

The rule an author is checked against, in order:

  1. Does their role grant posts:update:any (i.e. are they actually editor/admin)? If so, they can edit any post — ownership is irrelevant.
  2. Otherwise, does the post have an ownerId, and does it equal their token id? If both, they may edit it. If the post has no ownerId (legacy) or a different one, they get 403 FORBIDDEN — You may only edit posts you created.

Authors can never delete posts, owned or not — posts:delete is refused for author outright, before ownership is even considered.

Response envelope

Success:

{ "success": true, "data": { }, "meta": { "page": 1, "limit": 10, "total": 42, "totalPages": 5 } }

Error:

{ "success": false, "error": { "code": "NOT_FOUND", "message": "Post not found" } }

meta is present only on list endpoints.

A successful response may also carry warnings, an array of strings, when the request worked but did something you probably did not intend — today, only category values that matched no category:

{ "success": true, "data": { }, "warnings": ["1 category value(s) matched no category and were stored as-is: \"Ghost\". …"] }

The field is absent when there is nothing to report, so warnings in a response is always worth reading.

Posts

Method Endpoint Description
GET /api/blog/posts List posts
GET /api/blog/posts?slug=my-post One post by slug
GET /api/blog/posts?id=<id> One post by ID
POST /api/blog/posts Create
PUT /api/blog/posts?id=<id> Update
DELETE /api/blog/posts?id=<id> Soft delete

DELETE is a soft delete: it sets status to archived and returns { "deleted": true }. The document stays in the database and stops appearing in published listings. There is no hard-delete endpoint.

List query parameters: page (1), limit (10), category, tag, status, q or search, sortBy (publishedAt | createdAt | title), sortOrder (asc | desc, default desc).

Creating a post

{
  "title": "My Blog Post",
  "content": [{ "type": "paragraph", "content": [{ "type": "text", "text": "Hello world!" }] }],
  "contentHTML": "<p>Hello world!</p>",
  "excerpt": "A short summary of the post",
  "status": "published",
  "categories": ["tech"],
  "tags": ["nextjs", "blog"],
  "author": { "name": "John Doe", "bio": "Software engineer", "avatar": "https://example.com/avatar.jpg" },
  "seo": { "metaTitle": "My Blog Post | MySite", "metaDescription": "A short summary", "focusKeyword": "blog post" }
}
Field Type Required Notes
title string Yes
slug string No Generated from the title, made unique
content BlockContent[] No TipTap JSON
contentHTML string No
contentText string No Plain text, used for full-text search
excerpt string No Generated from content if omitted
status draft | published | scheduled | archived No Defaults to draft
categories string[] No Category slugs — names are accepted and resolved, see below
tags string[] No
author { name, bio?, avatar?, url? } No
seo { metaTitle?, metaDescription?, canonicalUrl?, ogImage?, ogType?, noIndex?, focusKeyword?, structuredData? } No
coverImage { _id, url, alt?, caption?, width?, height? } No
publishedAt ISO date No Set automatically when status becomes published
scheduledAt ISO date No

readingTime, wordCount, version and revisions are maintained by the server.

The categories contract

post.categories stores category slugs. It is what /blog/category/[slug] routes on and what postCount is counted with, so it is the stored form regardless of what you send.

You may send either a slug or a category name; the value is resolved to the canonical slug before it is stored, on create and on update, through the API and through createPost/updatePost called directly:

{ "categories": ["AI Search & Discovery"] }   →  stored as ["ai-search-discovery"]
{ "categories": ["ai-search-discovery"] }     →  stored as ["ai-search-discovery"]
{ "categories": ["  ai search & discovery "] } →  stored as ["ai-search-discovery"]

Matching rules, most specific first:

  1. an exact slug — the canonical form, never reinterpreted;
  2. a category name, compared case-insensitively and tolerant of leading, trailing and repeated whitespace;
  3. a slug under the same case/whitespace tolerance.

Case and whitespace are formatting, not content, and a name copied from a UI or a spreadsheet routinely differs by exactly that much. Matching stops there on purpose: punctuation is content, so "AI Search and Discovery" does not resolve to "AI Search & Discovery" — they are plausibly two different categories, and guessing would invent data.

Values that resolve to the same category are deduplicated, and blank entries are dropped.

A value that matches neither a slug nor a name is stored exactly as you sent it, and the response carries a warnings entry naming it and listing the slugs that do exist. It is not dropped, and no category is created for it — but be clear about what such a value is: it will not appear in any category listing, /blog/category/<value> will not find it, and it is not counted in postCount. Either create the category and run next-blogpanel migrate, or correct the post.

When categories is not an array (library callers)

Over HTTP this cannot happen: the schema rejects anything but an array of strings with a 400. But createPost, updatePost and resolvePostCategories are exported from the package root and are documented as callable directly, where nothing validates the shape first. Since 1.1.1:

createPost({ categories: 'Engineering', … })   // → stored as ["engineering"]
createPost({ categories: 'Ghost', … })         // → stored as ["Ghost"], warned as unmatched
createPost({ categories: 42, … })              // → stored as [],       warned as not-a-string
createPost({ categories: null, … })            // → stored as []

A string is one category value, never a sequence of characters — before 1.1.1, 'Engineering' was iterated and stored as ["E","n","g","i","e","r"] with no error at all. It is not split on commas either: that would invent a division you did not write, so an unmatched string is reported like any other unmatched value.

A value that is not a string cannot be a category — it cannot be matched, counted, routed to a category URL or rendered — so it is left out rather than coerced into "42" or "[object Object]", and named in a warning on the server console and in the response's warnings. Everything else in the field is stored as normal.

Pass an array. This is a guard against silent corruption, not an interface.

updatePost has one extra rule, because it can destroy something a create cannot. An update that mentions categories but carries no readable value in it — '', ' ', 0, false, null, 42, {…} — leaves the post's existing categories exactly as they are and says so on the console. Clearing them would mean deleting data on the strength of a value the package has just reported it could not read.

updatePost(id, { categories: ['engineering'] })  // → ['engineering']
updatePost(id, { categories: [] })               // → []            (the way to clear)
updatePost(id, { categories: 'engineering' })    // → ['engineering']
updatePost(id, { categories: '' })               // → unchanged, warned
updatePost(id, { })                              // → unchanged, silent (not mentioned)

An empty array is the way to clear the field. It is the one shape that unambiguously says "make this empty". Before 1.1.1 a falsy value bypassed all of this and $set the raw scalar — categories: "" — over the array.

What an update may set, and what it refuses

updatePost builds its write from the fields it has actually handled. It never spreads the input, so a field can no longer be written without passing through its own handling — which is how PUT {"content": ""} used to destroy a post's body and skip the revision that would have brought it back, and how PUT {"slug": ""} used to blank a permalink.

Field Rule
content Must be an array of blocks. [] clears the body and takes a revision. Anything else — "", null, 0, an object — is refused: 400 over HTTP, a TypeError from the library, and nothing at all is written, not even the other fields in the same call.
slug Must be a non-empty string. "" is refused the same way. (On create an empty slug still means "derive one from the title".)
categories See above — an unreadable value leaves the stored categories alone.
title, excerpt, contentHTML, contentText, coverImage, tags, author, seo, status, publishedAt, scheduledAt Written as given, when mentioned. Validated by the schema on the HTTP path.
anything else Ignored, with a warning naming it. wordCount, readingTime, version and revisions are derived from content; _id and createdAt never change.

updateCategory follows the same rules: a slug that is not a non-empty string is refused (it is what /blog/category/[slug] routes on and what every post's categories array points at), an unreadable parentId leaves the existing parent alone, and postCount is derived and cannot be set.

Omitting a field is always the way to leave it alone.

Migrating posts that already store names

Before 1.1.0 nothing validated this field, so posts created through the API may have category names stored in it. Normalising writes does nothing for data already in the database:

npx next-blogpanel migrate

The migration converts stored names to slugs, leaves already-slug values and unmatched values untouched, reports both, and recomputes postCount for every category involved. It is idempotent — running it twice changes nothing — and safe to run on a database that never had the problem.

A document whose categories field is not an array at all (legacy data, a hand edit, a foreign importer) is reported and skipped untouched — the migration will not guess at what it was meant to be, and one such document does not stop the run.

postCount

Category.postCount is the number of published posts in that category. It is refreshed on every post create, update and delete, and recomputed from scratch by next-blogpanel migrate. Categories a post joins and leaves are both refreshed, and a refresh that fails is logged rather than failing the write it follows — run the migration to repair.

Media

Method Endpoint Description
GET /api/blog/media List media — page, limit (20), mimeType. Requires auth.
POST /api/blog/media Upload, multipart/form-data
DELETE /api/blog/media?id=<id> Delete
GET /api/blog/media/file/<key> Stream a GridFS object. Public.

Upload fields: file (required), alt, caption. Returns 201 with the created media document.

Upload error codes:

Code Status Cause
MISSING_FILE 400 No file field
INVALID_TYPE 400 MIME type outside jpeg/png/gif/webp/svg+xml/avif
FILE_TOO_LARGE 413 Over editor.maxUploadSize (10MB), or an SVG over editor.maxImageSize (2MB)
IMAGE_TOO_LARGE 422 Compression could not reach editor.maxImageSize
CONFIG_NOT_INSTALLED 500 No config installed in this server runtime — see configuration.md
STORAGE_PROVIDER_NOT_REGISTERED 500 BLOGPANEL_STORAGE names a provider this runtime never registered — see storage.md

See storage.md for what happens between accepting a file and storing it.

DELETE /media response

{
  "success": true,
  "data": {
    "deleted": true,
    "storageDeleted": true,
    "storageKey": "media/2026/07/a1b2c3d4-photo.webp",
    "provider": "gridfs"
  }
}

provider is the name of the backend that was actually asked — gridfs, r2, or a custom one.

storageDeleted: false means the database row was removed but the file could not be — it is still in storage, unreachable through any UI, and still counted against your quota. A warning field is added in that case. The status stays 200 because the database half succeeded. Details in storage.md.

DELETE resolves the recorded provider before it removes the row, so a provider named on the document but not registered in this runtime returns 500 STORAGE_PROVIDER_NOT_REGISTERED with the row left intact — recoverable by registering the provider and retrying.

Categories

Method Endpoint Description
GET /api/blog/categories List. Public.
GET /api/blog/categories?slug=<slug> One by slug. Public.
POST /api/blog/categories Create — { name, slug?, description?, seo?, order?, parentId? }
PUT /api/blog/categories?id=<id> Update
DELETE /api/blog/categories?id=<id> Delete

Settings

Method Endpoint Description
GET /api/blog/settings Read. Requires auth — this is also what the admin panel uses to validate a key at login.
PUT /api/blog/settings Update

Fields: postsPerPage, defaultAuthor, defaultOgImage, commentSystem (none | giscus | disqus), commentConfig, customCSS, analytics.gaId, analytics.plausibleDomain, plus the five Crawlers & AI fields new in 1.3.0 — crawlerPolicy, llmsSummary, ogBackground, ogAccent, indexNowEnabled — see configuration.md for what each does and its default.

GET only requires a valid credential, of any role — it is also what the admin panel uses to validate a key at login. PUT requires settings:write (admin or the master key).

indexNowKey cannot be set here. It is a field on the stored settings document — GET returns it like any other, to whoever is authenticated to read settings at all — but it is not in BlogSettingsSchema, so PUT silently ignores it if you send it: not in what the schema validates, and not in what updateSettings is ever called with as a result. next-blogpanel migrate is the only thing that ever writes it, and the admin Settings page never reads or displays it. See configuration.md.

Tokens

Method Endpoint Description
GET /api/blog/tokens List. Requires tokens:write — there is no separate read-only tier, see Roles and permissions. Hashes are never returned.
POST /api/blog/tokens Create — { name, role }. role is required, one of admin | editor | author. Requires tokens:write. Returns the plaintext token once.
DELETE /api/blog/tokens?id=<id> Revoke. Requires tokens:write.

All three require tokens:write, which only admin (and the master key) hold — see the roles table. This is a change from 1.1.x: previously only the master key could manage tokens at all; now an admin-role token can too. See Upgrading to 1.2.0.

Audit

Method Endpoint Description
GET /api/blog/audit List audit entries, newest first. Requires audit:read (admin or the master key only — editor and author get 403).

Query parameters: page (1), limit (20), principalId (filter to one actor), resourceType (post | category | media | settings | token).

page is clamped to a minimum of 1 and limit to the range 1–100. Values outside those ranges are silently clamped rather than rejected: page=-1 would otherwise reach MongoDB as a negative skip and come back as a 500, and limit=0 means unbounded to MongoDB, so a single request would return the whole collection with meta.totalPages set to Infinity.

Every mutation of a post, category, media item, setting or token writes one entry to the nbk_audit collection — creates, updates and deletes, across every route above. Reads never write an entry, including reads of the audit log itself: GET /audit cannot make itself grow.

An entry looks like:

{
  "_id": "...",
  "at": "2026-07-26T10:15:00.000Z",
  "principal": { "kind": "token", "id": "...", "name": "Priya", "role": "editor" },
  "action": "posts:update:any",
  "resource": { "type": "post", "id": "...", "label": "How We Migrated to Roles" },
  "summary": "Updated post \"How We Migrated to Roles\""
}

principal is { "kind": "master" } when the master key acted — it carries no id, name or role, because the master key has none. resource.label is the resource's name at the time of the action, stored redundantly rather than looked up later, so an entry naming a deleted post still says what was deleted.

action is not the same vocabulary as the permission table. It records what the request actually did, and one value has no counterpart in Roles and permissions:

action Written when
posts:update:own An author updated a post they own. This is not a grantable permission and does not appear in the roles table — an author reaches PUT /posts through the ownership check (canEditPost), never through posts:update:any, which author is explicitly denied.
everything else Identical to the permission the request was gated on: posts:create, posts:update:any, posts:delete, categories:write, media:upload, media:delete, settings:write, tokens:write, redirects:write.

Recording an author's edit as posts:update:any would put an action against a principal the permission matrix refuses it, and an admin reading the trail would reasonably conclude the matrix had been bypassed. If you filter or alert on action, treat it as a superset of the permission names — import type { AuditAction } from 'next-blogpanel' — not as Action.

Writing an audit entry is best-effort. If the write itself fails — the database is briefly unreachable, for instance — the mutation it is logging still succeeds; the failure is logged to the server console, not surfaced to the caller. A successful POST /posts is never turned into a 500 because the audit trail couldn't be updated.

Entries expire. How long they are kept is the BLOGPANEL_AUDIT_RETENTION_DAYS environment variable (default 90 days; 0 keeps everything forever), applied as a MongoDB TTL index by next-blogpanel migrate. It is an environment variable and not a key in next-blogpanel.config.ts, because migrate runs as its own process and cannot read that file — see configuration.md.

Redirects

Method Endpoint Description
GET /api/blog/redirects List — public manifest, or the full record set once authenticated
POST /api/blog/redirects Create — { from, to, statusCode? }. Requires redirects:write
DELETE /api/blog/redirects?id=<id> Delete. Requires redirects:write

GET behaves differently authenticated vs not

GET takes no credential at all, but the shape of what it returns depends on whether one was sent:

  • No Authorization header — the public manifest: { "redirects": [{ "from", "to", "statusCode" }] }, carrying only what a redirect layer needs to act on. This is what the scaffolded middleware.ts fetches (see configuration.md) — the edge runtime cannot run the MongoDB driver, so this route is how it learns about redirects at all. It is ETag'd and cached for 60 seconds (Cache-Control: public, max-age=60, s-maxage=60); a matching If-None-Match returns 304. Publishing this is not a new disclosure: a redirect mapping is already discoverable by following it.
  • A valid Authorization header — the full record set, in the usual { success, data } envelope, one object per redirect with _id, from, to, statusCode, source (slug-change | manual), createdBy and createdAt. This is what the admin RedirectManager reads (see components.md).
  • An Authorization header that fails to authenticate — refused with the normal 403, not silently downgraded to the public manifest. A bad Bearer token gets an error, never a quieter version of a good one.

Hygiene rules

POST enforces four rules server-side, refusing with a 400 and a code your integration can match on when a request would violate one:

  1. No self-redirects. from === to is refused as SELF_REDIRECT — it would loop forever.
  2. No chains, in either creation order. Creating B → C rewrites any existing A → B to A → C; creating A → B while B → C already exists stores A → C outright. Either way what gets saved is one hop to where the target actually ends up, never an A → B → C a client has to follow twice.
  3. No loops. Creating X → Y deletes any existing Y → X — renaming a slug back to what it was cleanly undoes the redirect that renaming it away created. This is resolved before rule 2, so a rename back to a previous slug is stored as asked rather than refused for pointing at itself.
  4. No shadowing. Creating a redirect whose from is the URL of a currently-published post is refused as SHADOWS_LIVE_POST. A redirect can never hide real content; unpublish the post or choose another path.

Rule 4 has a counterpart on the post side, because a redirect can also become a shadow after it is created — one written while a post was still a draft, or left behind by another post's rename onto that slug. Publishing a post at a URL, renaming a published post onto one, or simply saving a published post deletes any redirect sitting at that URL: whatever the middleware and the blog catch-all find there wins over the post, so the post has to take its URL back. Redirects pointing to that URL are untouched — those are the ones keeping old inbound links alive.

Same from twice: the latest POST wins, keyed by a unique index on from.

Auto-redirect on a published slug change

Changing the slug of a published post (PUT /posts?id=) creates old → new as a side effect of the save — a 301, source: "slug-change", attributed to whoever made the edit. You do not call POST /redirects yourself for this case; it happens automatically so an inbound link to the old URL never breaks. A slug change on a draft post creates nothing — nothing was ever public at the old URL to protect. In the admin Redirects page these auto-created rows are visibly labeled slug change, distinct from a manually-created one.

Crawler surface

Five public, unauthenticated routes exist to make every published post as visible as possible to search engines and AI answer engines. All five follow the same rule: they degrade, never 500. A route whose data can't be read still returns a well-formed (if empty or default) response, with the problem logged via console.warn — a crawler-facing endpoint returning 500 reads to a crawler as "site broken," which is a worse outcome than an empty file.

Route Serves Notes
GET /robots.txt Crawl rules text/plain, 1-hour cache
GET /llms.txt An llms.txt-convention summary of published posts text/plain, 5-minute cache
GET /llms-full.txt The full body of every published post, as markdown text/plain, 5-minute cache
GET /api/blog/og/[slug] A generated 1200×630 Open Graph card, PNG Public, long immutable cache
GET /api/blog/indexnow.txt The site's IndexNow key text/plain, 1-day cache

robots.txt, llms.txt and llms-full.txt are scaffolded at your site root (app/robots.txt/route.ts, etc.), not under apiPath — the other two are scaffolded under it, same as every other API route.

llms.txt and llms-full.txt

Both follow the llmstxt.org convention: llms.txt is a short index (site name, a one- or two-sentence summary, then one Markdown link per post with its meta description or excerpt); llms-full.txt is the full corpus — every eligible post's title as an H1, a metadata line (URL, ISO publish date, author), and its body rendered as clean Markdown.

Eligibility is the same for both files: status === "published" and not seo.noIndex. Drafts, scheduled posts, archived posts and noIndex posts never appear in either file. Posts are ordered newest-first, with no cap in v1 — a blog large enough for that to matter can get a cap added later without a breaking change.

The summary line at the top of llms.txt comes from the llmsSummary Settings field (see configuration.md), falling back to a generated line naming the site if you haven't set one.

The Markdown body in llms-full.txt is rendered from the same block tree the HTML renderer uses — paragraphs, headings, lists, code, quotes, tables, callouts, FAQ, and HowTo blocks all have a Markdown form (FAQ blocks render as **Q:** / **A:** pairs, HowTo as an ordered list under its title). A block that fails to render, or a post whose Markdown comes out empty, falls back to the post's plain text for that post with a console.warn — it is never dropped from the file silently, and one post's bad content never fails the whole file.

robots.txt

User-agent: * gets Disallow: stanzas for the admin path and the API path, but with Allow: carve-outs first for {apiPath}/og/ and {apiPath}/media/file/ — a blanket API disallow would otherwise newly block the very crawlers (social-card fetchers, image bots) that need to load og:image and inline media. A Sitemap: line is appended when BLOGPANEL_SITE_URL is set.

AI crawlers are welcome by default. No stanza is emitted for a bot the owner hasn't touched — absence of a block is the permission. Only a bot explicitly set to "Block" in Settings → Crawlers & AI (see configuration.md) gets its own User-agent: <bot> / Disallow: / stanza. The known bots (GPTBot, ClaudeBot, Claude-User, PerplexityBot, Google-Extended, CCBot, Bytespider, Amazonbot, meta-externalagent) live in one exported constant, KNOWN_AI_CRAWLERS, so adding a new one later is a one-line change that touches no stored settings — the settings object only ever stores overrides, keyed by user agent, and an absent key still means "allow."

GET /api/blog/og/[slug]

Generates a branded 1200×630 PNG for a post the first time anyone asks for it — the site name, the post title (word-wrapped), author and date, over a background/accent pair from Settings (ogBackground/ogAccent, defaulting to #0f172a/#2563eb). Returns 404 for a slug that doesn't exist or isn't published. The rendered PNG is cached in a dedicated nbk_og_cache collection (not the media library), one row per slug, keyed by a hash of everything the card draws: the post title, BLOGPANEL_SITE_NAME, the author name, the published date as it is formatted on the card, and both colors. Change any of those and the next request re-renders; change a field the card does not draw and the cached PNG is served as-is. If rendering fails for any reason, the route falls back to settings.defaultOgImage if one is configured, otherwise a 404 — never a 500.

This is also the fallback of last resort in the og:image chain generateMetaTags builds: seo.ogImage (explicit) → the post's cover image → this generated card. Every published post ends up with some og:image.

Deployment note: the renderer draws SVG text through sharp/libvips, which resolves fonts via fontconfig — something serverless Linux images don't reliably ship. The package bundles its own OFL-licensed font and points fontconfig at it before the first render, but this is environment-dependent by nature and is this release's single highest-risk piece of infrastructure. Before relying on this in production, run pnpm vitest run tests/lib/og-image.test.ts on your actual deploy target's platform/image (not just your dev machine) as a smoke check — it renders a real card and asserts a non-empty, correctly-sized PNG. If it fails on your host, see the note in src/lib/og-image.ts: the renderer's internals are meant to swap to Satori behind the same renderOgCard function signature without anything above it changing.

FONTCONFIG_FILE is process-wide. The first OG card render sets process.env.FONTCONFIG_FILE to point fontconfig at the bundled font — for the whole Node process, not just this module. If your app (or another dependency) also renders SVG text through sharp/libvips and needs its own fontconfig setup, that render will see this package's font config too. The module respects a FONTCONFIG_FILE you've already set before the first OG card render runs — it will not overwrite it — so the workaround is to set FONTCONFIG_FILE yourself (pointing at a config that includes whatever fonts your own rendering needs, plus this package's bundled font if you still want cards to render) before anything in your app requests an OG card.

GET /api/blog/indexnow.txt

Serves the site's IndexNow key as plain text — see IndexNow below for what it's for. Returns 404 if next-blogpanel migrate hasn't generated a key yet.

IndexNow

IndexNow is a small protocol that lets a site push "this URL changed" notifications to participating search engines instead of waiting to be re-crawled. This package pings it automatically — you will not see this machinery unless you go looking for it.

Triggers, wired into the existing POST/PUT/DELETE /posts handlers as fire-and-forget calls (void pingForSlugs(...)) so a search engine being slow or unreachable never delays or fails the request that triggered it:

Transition URL(s) pinged
Draft/scheduled → published The post's URL
Slug change on a published post Both the old and the new URL
Published → unpublished/archived, or deleted The post's (former) URL, so the engine re-crawls and finds the redirect or the 404

Google is not pinged, on purpose. Google ignores IndexNow entirely — it isn't a partner in the protocol — and its own Indexing API is restricted to job-posting and broadcast-event content, neither of which applies here. Google instead learns about changes from the sitemap <lastmod> this package already emits at /api/blog/sitemap.xml; nothing further is needed for it.

Best-effort by design — the same philosophy as audit-trail writes: a 3-second timeout, and any failure (timeout, network error, non-2xx) is caught and logged with console.warn, never surfaced to the API caller. A publish always succeeds and returns promptly whether or not api.indexnow.org is reachable.

Off switch: the indexNowEnabled Settings field, default true. Turn it off in Settings → Crawlers & AI, or PUT /settings with { "indexNowEnabled": false }, and every trigger above becomes a no-op.

The key (settings.indexNowKey) is generated once by next-blogpanel migrate and never rotated automatically — see configuration.md. It cannot be set through PUT /settings: the field is absent from BlogSettingsSchema, so a client that sends indexNowKey in the request body has it silently stripped by validation before anything is written, the same way any other field the schema doesn't recognise would be. It never appears in the admin UI either — there is nothing to configure here beyond the on/off switch.

Known limitations

page and limit are unclamped on GET /posts and GET /media

Both endpoints pass ?page= and ?limit= straight through to MongoDB without bounds. Two consequences, on /api/blog/posts and /api/blog/media alike:

Request What happens
?page=-1 Produces a negative skip, which MongoDB rejects. Surfaces as a 500 with INTERNAL_ERROR.
?limit=0 .limit(0) means unbounded to MongoDB, so the response contains the entire collection in one payload, with meta.totalPages serialised as null (it is Infinity).

GET /posts is public — it takes no credential at all — so both are reachable unauthenticated. GET /media requires auth. Neither discloses anything a caller could not already read through normal paging; the concern is the 500 and the unbounded response, which is a resource-exhaustion vector on a public endpoint.

This is present unchanged in 1.1.1 and is not new in 1.2.0. It is not fixed here because clamping limit is a behaviour change for any existing integration that pages with a large explicit value (?limit=500 returns 500 posts today), so it needs a maximum chosen deliberately, tests, and a release note rather than a quiet clamp in a patch. Planned for a following release.

GET /audit is already clamped — page to >= 1, limit to 1…100 — because that route is new in 1.2.0 and has no integration to break. See Audit.

Until this is fixed, put a bound in front of the public route if that matters for your deployment: a limit cap in a reverse proxy or middleware, or rate limiting on /api/blog/posts.

Sitemap and RSS

Method Endpoint
GET /api/blog/sitemap.xml
GET /api/blog/rss.xml

Both are generated from published posts and categories, both are public, and both use BLOGPANEL_SITE_URL and BLOGPANEL_SITE_NAME.

Package exports

// Root — config, database helpers, SEO, types
import { defineConfig, setConfig, isConfigInstalled, getConfig, getEnvConfig, getBlogConfig, ConfigNotInstalledError } from 'next-blogpanel';
import { getDb, getCollection, ensureIndexes } from 'next-blogpanel';
import { createPost, updatePost, deletePost, getPostBySlug, getPostById, listPosts } from 'next-blogpanel';
import { createCategory, updateCategory, deleteCategory, listCategories, getCategoryBySlug } from 'next-blogpanel';
import { resolveCategorySlugs, resolvePostCategories, categoryLabel, humanizeSlug, normalizeCategoryKey, unmatchedCategoryWarning } from 'next-blogpanel';
import type { CategoryRef, ResolvedCategories } from 'next-blogpanel';
import { blogNode, organizationNode, personNode, webSiteNode } from 'next-blogpanel';
import type { BlogNode, OrganizationNode, PersonNode, WebSiteNode } from 'next-blogpanel';
import { createMedia, deleteMedia, listMedia, getSettings, updateSettings } from 'next-blogpanel';
import { generateSlug, ensureUniqueSlug, searchPosts } from 'next-blogpanel';
import { processImage, ImageTooLargeError, absoluteUrl, isAbsoluteUrl } from 'next-blogpanel';
import { getStorageProvider, getStorageProviderByName, GridFSStorageProvider, R2StorageProvider } from 'next-blogpanel';
import { registerStorageProvider, unregisterStorageProvider, listStorageProviderNames, isStorageProviderRegistered } from 'next-blogpanel';
import { StorageProviderNotRegisteredError, BUILT_IN_PROVIDER_NAMES, generateKey } from 'next-blogpanel';
import type { StorageProvider, MediaUploadResult, StorageObject, ProcessedImage, BlogPanelUserConfig } from 'next-blogpanel';
import { calculateReadingTime, countWords, extractTextFromHTML, extractTextFromBlocks } from 'next-blogpanel';
import { generateMetaTags, generateStructuredData, generateFAQStructuredData, generateHowToStructuredData, generateBreadcrumbs, articleEntityNodes } from 'next-blogpanel';
import { calculateSEOScore, generateSitemap, generateRSSFeed } from 'next-blogpanel';
import { renderBlocksToHTML, extractHeadings, extractFAQItems } from 'next-blogpanel';
import type { BlogPost, Category, Media, BlogPanelConfig, ApiResponse, SEOScore } from 'next-blogpanel';
import type { AuditEntry, AuditAction, AuditListQuery } from 'next-blogpanel';

// Permissions and audit
import { can, canEditPost } from 'next-blogpanel';
import type { Role, Principal, Action } from 'next-blogpanel';
import { recordAudit, listAudit, toAuditPrincipal } from 'next-blogpanel';
import type { AuditEntry, AuditListQuery } from 'next-blogpanel';

// Server-side subset, for use inside route files and server components
import { getConfig, setConfig, isR2Configured, getDb, listPosts, getPostBySlug, getPostById } from 'next-blogpanel/lib';
import { getStorageProvider, getStorageProviderByName, registerStorageProvider, processImage } from 'next-blogpanel/lib';
import type { StorageProvider } from 'next-blogpanel/lib';
import { listCategories, getCategoryBySlug, generateMetaTags, calculateSEOScore } from 'next-blogpanel/lib';
import { categoryLabel, resolveCategorySlugs, resolvePostCategories } from 'next-blogpanel/lib';
import { can as canLib, canEditPost as canEditPostLib } from 'next-blogpanel/lib';
import type { Role as RoleLib, Principal as PrincipalLib, Action as ActionLib } from 'next-blogpanel/lib';
import { KNOWN_AI_CRAWLERS, generateRobotsTxt } from 'next-blogpanel/lib';
import { generateLlmsTxt, generateLlmsFullTxt, eligibleForLlms } from 'next-blogpanel/lib';
import type { LlmsOptions } from 'next-blogpanel/lib';
import { normalizeRedirectPath, createRedirect, listRedirects, deleteRedirect, resolveRedirect, reclaimUrl } from 'next-blogpanel/lib';
import type { CreateRedirectResult } from 'next-blogpanel/lib';
import { INDEXNOW_ENDPOINT, generateIndexNowKey, pingForSlugs } from 'next-blogpanel/lib';

// Blog components (client)
import { BlogListPage, BlogPostPage, BlogCard, BlogSearch } from 'next-blogpanel/components';
import type { BlogListSlots, BlogPostSlots } from 'next-blogpanel/components';

// Admin panel (client)
import { AdminLayout, Dashboard, PostList, PostEditor } from 'next-blogpanel/admin';
import { MediaLibrary, CategoryManager, SettingsPage, SEOPanel, AuditLog, RedirectManager } from 'next-blogpanel/admin';
import { useAdminApi, setApiBase, getApiBase, setBasePath, getBasePath, setAdminPath, getAdminPath } from 'next-blogpanel/admin';

// Editor (client)
import { BlogEditor, type BlogEditorProps } from 'next-blogpanel/editor';
import { SlashCommand, defaultSlashCommands, SUPPORTED_LANGUAGES } from 'next-blogpanel/editor';
import { FAQ, FAQItem, FAQQuestion, FAQAnswer, HowTo, HowToTitle, HowToStep } from 'next-blogpanel/editor';

// Edge redirect resolution for every path the manifest covers — see docs/configuration.md#redirects-and-middlewarets
import { createBlogRedirectMiddleware } from 'next-blogpanel/middleware';

// Route handlers — re-export from your route.ts files
export { GET, POST, PUT, DELETE } from 'next-blogpanel/api/posts';
export { GET, POST, DELETE } from 'next-blogpanel/api/media';
export { GET } from 'next-blogpanel/api/media-file';
export { GET, POST, PUT, DELETE } from 'next-blogpanel/api/categories';
export { GET } from 'next-blogpanel/api/audit';
export { GET, PUT } from 'next-blogpanel/api/settings';
export { GET, POST, DELETE } from 'next-blogpanel/api/tokens';
export { GET, POST, DELETE } from 'next-blogpanel/api/redirects';
export { GET } from 'next-blogpanel/api/sitemap';
export { GET } from 'next-blogpanel/api/rss';
export { GET } from 'next-blogpanel/api/robots-txt';
export { GET } from 'next-blogpanel/api/indexnow-txt';
export { GET } from 'next-blogpanel/api/og-image';

// llms.txt and llms-full.txt are two different routes, so — unlike every
// other line above — they need two separate route.ts files, not one:
//   app/llms.txt/route.ts:      export { GET } from 'next-blogpanel/api/llms-txt';
//   app/llms-full.txt/route.ts: export { GET_FULL as GET } from 'next-blogpanel/api/llms-txt';
// `export { GET, GET_FULL }` from a single file is not valid Next.js route
// handler shape — GET_FULL is not a name Next's route validation recognizes,
// and the two files serve different URLs regardless.

// Styles
import 'next-blogpanel/styles/blog.css';
import 'next-blogpanel/styles/admin.css';
import 'next-blogpanel/styles/editor.css';
import 'next-blogpanel/styles/prose.css';
import 'next-blogpanel/styles/globals.css';

Upgrading to 1.2.0

1.2.0 adds roles, post ownership and the audit trail. It adds two files your app does not have, and beyond that needs a migration run, a decision, and one deliberate choice about roles.

Add the two new audit files

This step is not optional and nothing does it for you. The admin sidebar in 1.2.0 renders an Audit Log nav item unconditionally, but the page it links to and the API route behind it are init scaffolding — files that live in your app, not in the package. An install that upgrades without adding them gets a nav item that leads straight to a Next.js 404.

Create both files. They are one line of real content each, and the leading import is the generated setup module every other scaffolded file already imports (adjust the ../ depth if your paths differ from the defaults, and add ../ for a src/app/ layout):

app/admin/blog/audit/page.tsx

import '../../../next-blogpanel.setup';
import { AuditLog } from 'next-blogpanel/admin';

export default function AdminAudit() {
  return <AuditLog />;
}

app/api/blog/audit/route.ts

import '../../../next-blogpanel.setup';
export { GET } from 'next-blogpanel/api/audit';

Alternatively, re-run the scaffolder — it skips every file that already exists and writes only what is missing:

npx next-blogpanel init --blog-path /blog --admin-path /admin/blog --api-path /api/blog

Pass the same paths you originally used. init defaults to /blog, /admin/blog and /api/blog; running it bare against an install scaffolded to different paths creates a second, orphaned tree instead of filling in the gaps.

If you would rather not have the page at all — for instance because everyone who signs into your panel is an editor — the nav item cannot currently be hidden (AdminLayout has no way to learn the signed-in principal's role; see components.md). Adding the two files is the supported path.

Run the migration

npx next-blogpanel migrate

This does three things relevant to this release:

  • Backfills every existing API token to the admin role. Tokens created before 1.2.0 carry no role at all; requireAuth also independently falls back to admin for a roleless token even if you skip this step, so nothing breaks if you upgrade without migrating first — migrate just makes the backfill permanent in the database instead of happening implicitly on every request. Backfilling to anything less than admin was not an option: your existing tokens, including whatever runs your content pipeline, had unrestricted access before 1.2.0, and a lesser default would have silently cut off every integration on upgrade.
  • Creates the TTL index for the audit trail, sized from BLOGPANEL_AUDIT_RETENTION_DAYS (default 90 days, 0 to keep entries forever). Set it in .env.local or your deployment environment before running migrate if 90 days is not what you want — it is an environment variable, not a config-file key, because migrate is a separate process that cannot read next-blogpanel.config.ts. Every run prints the value it applied and where it came from. Set it everywhere migrate runs, not only where the app runs — a run without it will not rewrite a window you already chose, but it also will not apply the one you meant. See configuration.md.
  • Everything migrate already did in earlier versions (category slug normalisation, media schema fixes) — unchanged.

Assign narrower roles deliberately

Every token is admin immediately after migrating — the same access it had in 1.1.x, just now explicit. That is correct as a starting point, but it is not the end state for a token that should not have had settings:write or tokens:write in the first place. Revoke (DELETE /tokens?id=) and reissue (POST /tokens with an explicit role, or use the Role selector on the admin Settings page — see components.md) any token that should be editor or author instead. There is no PATCH /tokens to change a role in place; reissuing is the only way to change one.

Legacy posts need an owner before an author can edit them

Every post that existed before 1.2.0 has no ownerId, and there is no automatic way to give it one — updatePost never sets ownerId, even when an editor or admin saves the post. Left alone, these posts are editor/admin-only forever. If you are assigning the author role to anyone, decide up front: either that person's older posts stay off-limits to them (usually fine — an author picks up authorship going forward, not retroactively), or you set ownerId on specific documents directly in MongoDB, to that person's token _id as a string, before they need to touch them.

One more behaviour change worth knowing about

In 1.1.x, only the master key (BLOGPANEL_API_KEY) could create, list or delete API tokens. In 1.2.0, any token with the admin role can too — see Tokens. If you don't want a given token to be able to mint or revoke other tokens, give it editor or author instead of admin.

Upgrading to 1.3.0

1.3.0 adds the AEO surface described throughout this document — llms.txt/llms-full.txt, robots.txt, IndexNow, redirects, generated OG cards, and AEO checks in the scorer. Almost all of it is opt-in: it degrades to simply not being there without its scaffolded file. Two things do change behaviour on a version bump alone, and both are below — the Redirects nav item (the same shape as the 1.2.0 Audit Log gotcha) and og:image.

og:image now points at the OG card route, whether or not you scaffolded it

generateMetaTags() used to leave og:image/twitter:image empty for a post with no seo.ogImage and no coverImage. It now falls back to {apiPath}/og/{slug}, the generated-card route — and that route is init scaffolding, not part of the package. On an install that bumps the version without re-scaffolding, those posts advertise an og:image that 404s, which is worse than the empty one they had before: a social crawler follows it and gets nothing, where before it fell back to whatever the platform does with no image at all.

Do one of these:

  • Scaffold the routeapp/api/blog/og/[slug]/route.ts (adjust the ../ depth for a src/app/ layout):

    import '../../../../next-blogpanel.setup';
    export { GET } from 'next-blogpanel/api/og-image';

    Or re-run init as shown below, which writes it along with everything else missing.

  • Or give every post an image of its own, so the fallback is never reached. The chain is seo.ogImagecoverImage.url → generated card, and anything earlier in it wins. Note that Settings → defaultOgImage does not help here: generateMetaTags() never reads it. It is the OG route's own fallback — served as a 302 when card rendering fails — so it only takes effect once the route exists.

Nothing else in 1.3.0 changes the output of a route you already had.

Add the Redirects page and route (same trap as Audit Log in 1.2.0)

AdminLayout's sidebar now renders a Redirects nav item unconditionally, exactly like Audit Log did in 1.2.0 — and exactly like that case, the page it links to and the API route behind it are init scaffolding, not part of the package. An install that upgrades the package without re-scaffolding gets a nav link straight to a Next.js 404.

Create both files by hand (adjust the ../ depth for a src/app/ layout, same as every other scaffolded file):

app/admin/blog/redirects/page.tsx

import '../../../next-blogpanel.setup';
import { RedirectManager } from 'next-blogpanel/admin';

export default function AdminRedirects() {
  return <RedirectManager />;
}

app/api/blog/redirects/route.ts

import '../../../next-blogpanel.setup';
export { GET, POST, DELETE } from 'next-blogpanel/api/redirects';

Or re-run the scaffolder with the same paths you originally used — it skips every file that already exists and writes only what is missing:

npx next-blogpanel init --blog-path /blog --admin-path /admin/blog --api-path /api/blog

The rest of the AEO surface is opt-in, not nav-linked

Nothing links to /robots.txt, /llms.txt, /llms-full.txt, {apiPath}/indexnow.txt or middleware.ts from anywhere the admin panel renders, so skipping them does not produce a 404 trap the way the Redirects page does — they simply don't exist until you add them, the same "not present" degrade every other scaffolded file follows (see What init scaffolds). {apiPath}/og/[slug] is the exception: nothing links to it either, but every post's og:image now points at it — see the section above. Re-running init as above adds all of them at once, alongside middleware.ts at the project root (see configuration.md).

Run the migration once more

npx next-blogpanel migrate

Generates the IndexNow key the first time it finds none (see configuration.md) and creates the unique index the redirects collection needs. Safe to run on every upgrade, including ones that touch nothing described here — it is the same idempotent command 1.1.0 and 1.2.0 already asked you to re-run.

Editor slash commands

Type / in the editor to open the command menu.

Command Description
Heading 2 Section heading
Heading 3 Subsection heading
Heading 4 Sub-subsection heading
Bullet List Unordered list
Numbered List Ordered list
Task List Checklist with checkboxes
Blockquote Quote block
Code Block Syntax-highlighted code
Divider Horizontal rule
Image Upload from disk
Media Library Pick from uploaded images — appears only when onBrowseMedia is passed to BlogEditor
Table 3x3 table with a header row
Callout Info callout box
FAQ FAQ section with schema markup
How-To Step-by-step guide with schema markup — a title plus ordered steps, HowTo structured data emitted whenever the block is present, and its own markdown form for llms-full.txt
Table of Contents Auto-generated from headings

Media Library is inserted in place of Image when available; the admin PostEditor wires it up for you.

Custom image upload

BlogEditor takes an uploadImage handler. The admin PostEditor supplies one that posts to ${apiPath}/media; supply your own if you use the editor directly:

import { BlogEditor } from 'next-blogpanel/editor';

function MyEditor() {
  const handleUpload = async (file: File) => {
    const formData = new FormData();
    formData.append('file', file);

    const res = await fetch('/api/blog/media', {
      method: 'POST',
      headers: { Authorization: `Bearer ${apiKey}` },
      body: formData,
    });

    const { data } = await res.json();
    return { url: data.url, alt: file.name };
  };

  return <BlogEditor uploadImage={handleUpload} onChange={(c) => console.log(c)} />;
}

Without an uploadImage handler, BlogEditor falls back to a blob: URL and logs a warning. That is a placeholder for local experimentation only — the image never leaves the browser and disappears on reload. It is not what the admin panel does, and it has nothing to do with which storage backend you use.