Configuration

Two things configure Next BlogPanel: environment variables, which carry secrets and connection details, and next-blogpanel.config.ts, which carries behaviour.

Environment variables

Every variable is prefixed BLOGPANEL_. They are read on the server only — none of them is exposed to the browser.

Required

Variable Notes
BLOGPANEL_MONGODB_URI Any MongoDB connection string. Atlas, self-hosted, or mongodb://localhost:27017/blog.
BLOGPANEL_API_KEY Master key for the admin panel and the API. Must be at least 32 characters — startup fails otherwise. Generate one with openssl rand -hex 32.

Optional

Variable Default Notes
BLOGPANEL_MONGODB_DB database named in the URI Overrides the database name.
BLOGPANEL_SITE_URL "" Absolute site URL. Used for canonical tags, sitemap entries and RSS links.
BLOGPANEL_SITE_NAME "Blog" Appears in meta titles, RSS and the sitemap.
BLOGPANEL_STORAGE auto gridfs or r2. Forces a backend regardless of what else is set.
BLOGPANEL_R2_ACCOUNT_ID Cloudflare account ID.
BLOGPANEL_R2_ACCESS_KEY R2 access key ID.
BLOGPANEL_R2_SECRET_KEY R2 secret access key.
BLOGPANEL_R2_BUCKET Bucket name.
BLOGPANEL_R2_PUBLIC_URL Public base URL of the bucket, e.g. https://media.example.com.
BLOGPANEL_AUDIT_RETENTION_DAYS 90 Days the audit trail keeps entries. 0 keeps them forever. Applied by next-blogpanel migrate as a TTL index — see Audit retention.

The five BLOGPANEL_R2_* variables are all-or-nothing. When every one of them holds a non-empty value, R2 becomes the storage backend automatically; otherwise MongoDB GridFS is used. There is no partial mode. Because the check only tests for non-emptiness, leaving the placeholder values from the generated .env.local.example in place is enough to select R2 — see storage.md.

Configuration errors are reported at startup as a single Next BlogPanel configuration error: message listing every missing or invalid variable.

next-blogpanel.config.ts

init writes a minimal file:

import { defineConfig } from 'next-blogpanel';

export default defineConfig({
  basePath: '/blog',
  adminPath: '/admin/blog',
  apiPath: '/api/blog',
  auth: {
    strategy: 'api-key',
  },
});

Anything you omit falls back to the default below, including individual keys inside editor, seo, auth, features, theme and hooks — you can set editor.maxImageSize on its own without restating every other key in that group. defineConfig is a typing helper — it returns its argument unchanged, so the file is just an object with BlogPanelUserConfig inference attached.

This file configures the server runtime only. Anything the next-blogpanel CLI acts on lives in the environment instead, because the CLI is a separate process that never loads this file — audit retention is the one setting where that distinction is easy to get wrong.

Getting the config to the server

The config file does nothing on its own. Nothing in the package looks for it, imports it, or reads it from disk — it reaches the server only because something imports it and hands it over:

// app/next-blogpanel.setup.ts — written by `init`
import { setConfig } from 'next-blogpanel';
import config from '../next-blogpanel.config';

setConfig(config);

init generates that module inside your app directory and adds a one-line import '<…>/next-blogpanel.setup'; to the top of every file it scaffolds — every API route, both layouts, every page. That looks redundant and it is not. Next.js instantiates server modules per route entry, so module-level state set while handling one route is not guaranteed to be visible to the copy of that module serving another. setConfig() stores the merged config on globalThis, which every bundle in the process shares, and importing the setup module everywhere guarantees that whichever route Next.js loads first has already installed it.

If you write your own route handler or server component that calls a next-blogpanel API, add the same import to it. One line at the top:

import '../../next-blogpanel.setup';

Omitting it is usually harmless — a second route in the same process will have installed the config already — but "usually" is the problem, and one value refuses to depend on it. GridFS media URLs are built from apiPath and then written permanently into the media document and into the body of every post that embeds the image, so they cannot be repaired by fixing the config later. Rather than guess /api/blog, an upload that reaches a runtime with no config installed fails with:

Next BlogPanel: no configuration has been installed in this server runtime, so
the URL for this GridFS upload cannot be resolved.

Nothing is written to storage when that happens. Add the setup import to the route and retry.

setConfig is exported from next-blogpanel and next-blogpanel/lib, so you can call it directly instead of using the generated module if you prefer, and you can call it more than once. Each call merges over whatever is currently installed — the defaults on the very first call in this process, and the previously installed config on every call after that — not over the defaults every time. That distinction matters the moment you call it a second time yourself: if the generated setup module has already installed your full config and you then call setConfig({ theme: { darkMode: false } }) from your own code, only theme.darkMode changes. Every other key, including apiPath, keeps the value already installed rather than resetting to its default. Redundant calls with identical arguments — which is what happens when the setup module is imported by many routes and evaluated more than once — are safe and order-independent either way.

Changing apiPath in a later call is still allowed, but not silent: setConfig logs a console warning naming the old and new value, because apiPath is baked into GridFS media URLs already written to the database and into the body of every post that embeds one of those images.

Paths

Key Default What it controls
basePath /blog Public blog URL prefix. Post links, category links, breadcrumbs, share URLs.
adminPath /admin/blog Admin panel URL prefix. Sidebar nav targets.
apiPath /api/blog API URL prefix. Every admin fetch, the search box, and the URL GridFS images are served from.

These three are independent and none of them has to contain the word "blog". Set them at init time with --blog-path, --admin-path and --api-path, and the flags flow into the scaffolded folder names, the props inside the generated files, and this config file at once.

init rejects two shapes of input before writing anything:

  • A path that resolves to the site root — /, "", /// — because it would collide with the host app's own app/page.tsx.
  • Two paths that resolve to the same directory, because one tree would silently be dropped on top of the other.

Leading and trailing slashes are normalised, so articles, /articles and /articles/ are the same input.

Pagination and excerpts

Key Default Notes
postsPerPage 10 Default page size.
excerptLength 160 Characters used when an excerpt is auto-generated from content.
codeHighlighter 'shiki' 'shiki' or 'prism'.

editor

Key Default Notes
blocks all 16 block types paragraph, heading, image, codeBlock, blockquote, bulletList, orderedList, taskList, table, embed, horizontalRule, callout, tableOfContents, faq, howTo, html. Informational today — nothing in the package reads this list. BlogEditor registers every block type unconditionally, so shortening it does not remove a block from the editor.
maxImageSize 2 * 1024 * 1024 Ceiling on the stored result. Enforced by compression, never by rejection.
maxUploadSize 10 * 1024 * 1024 Ceiling on the raw upload. Enforced by rejection, before the file is buffered.
imageFormats ['jpg','jpeg','png','gif','webp','svg']
autosaveInterval 30000 Milliseconds between editor autosaves.

The two size keys point in opposite directions and are easy to swap by mistake:

  • Raise maxUploadSize to let bigger originals in the door. Anything above it gets a 413 FILE_TOO_LARGE.
  • Raise maxImageSize to allow bigger files to be kept. It is a compression target, not a gate — a 5MB photo under a 2MB ceiling is stored at under 2MB, not rejected.

Both defaults live in src/lib/limits.ts so there is exactly one definition of each number. The full behaviour, including what happens when compression cannot reach the target, is in storage.md.

seo

Key Default Notes
titleTemplate '%s | %siteName%'
defaultOgImage Fallback Open Graph image.
generateRSS true
generateSitemap true
structuredData true JSON-LD output.
minContentLength 300 Word count the SEO scorer treats as the minimum for a pass.

features

All default to true: search, relatedPosts, readingProgress, tableOfContents, shareButtons, darkMode, scheduling, revisionHistory, imageOptimization.

auth

Key Default Notes
strategy 'api-key' 'api-key', 'custom' or 'credentials'.
verify (request: Request) => Promise<boolean> for 'custom'.
admins Optional list of admin identifiers.

theme

theme.variables is a Record<string, string> of --nbp-* overrides and theme.darkMode toggles the dark palette. See theming.md.

hooks

Optional async callbacks: beforePublish(post), afterPublish(post), beforeDelete(post), onMediaUpload(media).

Crawlers & AI

Five fields new in 1.3.0 control the AEO surface — llms.txt, robots.txt, generated OG cards and IndexNow. Unlike everything above, these are not next-blogpanel.config.ts keys. They live on the Settings document in MongoDB (nbk_settings), the same place postsPerPage and commentSystem already live, and are edited the same way: Settings → Crawlers & AI in the admin panel, or PUT /api/blog/settings (see api.md). All five are optional; an install that never touches any of them gets every default below.

Field Type Default What it does
crawlerPolicy Record<string, 'allow' | 'block'> {} Per-bot overrides for the AI crawlers in KNOWN_AI_CRAWLERS (GPTBot, ClaudeBot, Claude-User, PerplexityBot, Google-Extended, CCBot, Bytespider, Amazonbot, meta-externalagent). Stores only overrides, keyed by user agent — an absent key means allow. Read by /robots.txt; a new bot added to KNOWN_AI_CRAWLERS later changes no data already stored.
llmsSummary string (≤ 500 chars) The blockquote summary at the top of llms.txt. Falls back to a generated one-liner naming the site when unset.
ogBackground hex color #rrggbb #0f172a Background of the generated OG card at /api/blog/og/[slug].
ogAccent hex color #rrggbb #2563eb Accent stripe and site-name color on the generated OG card.
indexNowEnabled boolean true false turns every IndexNow trigger (publish, slug change, unpublish, delete) into a no-op — see api.md.

AI crawlers are welcome by default — the deliberate stance this release takes. crawlerPolicy starts empty, and stays empty until you explicitly block a bot; nothing needs to be turned on for ChatGPT, Claude, Perplexity or any other AI answer engine to be able to read your posts. Blocking one is a select away in Settings → Crawlers & AI.

IndexNow key

A sixth field, indexNowKey, lives on the same settings document, but does not belong on the table above: it is not something you set. It is absent from the schema PUT /api/blog/settings validates, so sending it does nothing, and the admin Settings page never displays it (there is nothing to show — see api.md). next-blogpanel migrate is the only thing that ever writes it, generating a fresh key the first time it finds none:

npx next-blogpanel migrate

Running migrate again never overwrites an existing key. This is the same guard philosophy as audit retention's refusal to let a default silently replace a window someone chose, applied to a simpler case: a key that already exists is somebody's key — search engines that already indexed it as this site's keyLocation depend on it staying the same, so migrate only ever creates a missing one, never regenerates one that's already there.

Redirects and middleware.ts

init scaffolds middleware.ts at your project root (src/middleware.ts if you use a src/app layout) — the one new scaffolded file that does not live inside app/, because that is where Next.js requires middleware to be:

import { createBlogRedirectMiddleware } from 'next-blogpanel/middleware';

const redirects = createBlogRedirectMiddleware({ apiPath: '/api/blog' });

export default async function middleware(request: import('next/server').NextRequest) {
  return redirects(request);
}

export const config = {
  matcher: ['/((?!_next/|favicon.ico|.*\\.(?:png|jpg|jpeg|gif|webp|svg|ico|css|js|txt|xml)$).*)'],
};

What it does. Serves every redirect in the table at the edge — manual ones created from the Redirects admin page or POST /api/blog/redirects, slug-change ones created automatically, on blog paths and anywhere else on the site alike. It is not scoped to paths outside the blog; it matches whatever the manifest contains against the request path.

The scaffolded blog catch-all page (/blog/[slug]) checks MongoDB directly for the blog's own slugs, which is what covers them when this middleware's cache is cold — the very first request to an instance — or when there is no middleware at all, because the file was skipped or never scaffolded. The two overlap on blog paths by design: the middleware is the cheaper of the pair once warm, and the catch-all is the one that cannot be missing.

The Edge runtime it runs in cannot load the MongoDB driver, so instead it fetches the public redirect manifest (GET {apiPath}/redirects, unauthenticated, ETag-revalidated — see api.md) and keeps the parsed map in memory for 60 seconds per instance: one network round trip per instance per minute, a plain map lookup on every other request. Concurrent requests that arrive while a refresh is in flight share it rather than each starting their own, so a cold instance makes one fetch, not one per request. That fetch is abandoned after 2 seconds — middleware sits in front of every matched request, so an unresponsive manifest route must not be able to hold them open.

If a manifest fetch fails — a cold start, a slow instance, a transient network blip, that 2-second timeout — the middleware fails open: it keeps whatever map it already had (or an empty one, on the very first request) and passes the request through unredirected rather than risk taking the whole site down over a redirect table.

The matcher excludes _next/, favicon.ico, and common static-asset extensions (png, jpg, jpeg, gif, webp, svg, ico, css, js, txt, xml), so none of that cost is paid on asset requests. Narrow it further for routes of your own that redirects never need to touch — anything excluded here skips the manifest lookup entirely for that request.

A pre-existing middleware.ts is never overwritten. If init finds one already in place, it is skipped, exactly like every other file init finds already there (see What init scaffolds) — logged as:

⚠ Skipping middleware.ts (already exists) — add createBlogRedirectMiddleware to it yourself; see docs/configuration.md.

Wire it in by hand alongside whatever your middleware already does — createBlogRedirectMiddleware returns undefined when nothing matched, so falling through to your own logic is a plain if:

import { createBlogRedirectMiddleware } from 'next-blogpanel/middleware';
import type { NextRequest } from 'next/server';

const blogRedirects = createBlogRedirectMiddleware({ apiPath: '/api/blog' });

export default async function middleware(request: NextRequest) {
  const redirected = await blogRedirects(request);
  if (redirected) return redirected;
  return yourExistingMiddlewareLogic(request);
}

createBlogRedirectMiddleware(opts) takes apiPath (required — must match the API prefix you scaffolded with) and an optional ttlMs (default 60000) if you want the manifest refreshed more or less often than once a minute.

Audit retention

Every mutation of a post, category, media item, setting or token is recorded to nbk_audit; see api.md for what gets written and when. How long those entries are kept is set by one environment variable:

Variable Default Notes
BLOGPANEL_AUDIT_RETENTION_DAYS 90 Days to keep audit entries. 0 keeps them forever.

It is an environment variable, not a key in next-blogpanel.config.ts. That is not a stylistic choice. next-blogpanel migrate is the only thing that acts on this value, it runs as its own process, and it cannot read your TypeScript config file — doing so would need a transpiler the package does not ship and would mean executing your config, hooks and all, inside the CLI. Put it in .env.local (which the CLI reads, with the same precedence Next.js uses) or in your deployment environment:

# .env.local
BLOGPANEL_AUDIT_RETENTION_DAYS=365

Retention is enforced by a MongoDB TTL index on the entry's timestamp, sized at days * 86400 seconds, and migrate is what creates it:

npx next-blogpanel migrate

Every run prints the window it applied and where the number came from, so "90 days because that is what I asked for" is never confusable with "90 days because nothing I set was read":

  ✓ Audit TTL index ensured (retention: 365 day(s), from BLOGPANEL_AUDIT_RETENTION_DAYS)
  ✓ Audit TTL index ensured (retention: 90 day(s), from the built-in default — set BLOGPANEL_AUDIT_RETENTION_DAYS to change it)

0 means "never expire": migrate does not create the TTL index at all in that case, and drops one it previously created, rather than creating an index with an effectively-infinite value. 0 can only ever come from setting the variable — the default is 90 — so it is always a deliberate action, which matters for the rule below.

A value that is not a whole number — ninety, 90d, -1, 1.5 — makes migrate stop with an error and change nothing. It deliberately does not fall back to 90: this number becomes a TTL index, a TTL index deletes rows permanently, and a retention window nobody asked for is not a recoverable mistake.

Changing the window later

Run migrate again; there is nothing to do by hand.

# change BLOGPANEL_AUDIT_RETENTION_DAYS, then:
npx next-blogpanel migrate

The TTL index is named at_1 (MongoDB's default name for an ascending index on at). MongoDB refuses to redefine expireAfterSeconds on an existing index of the same key pattern via a plain createIndex call — a hard conflict ("An equivalent index already exists with the same name but different options"), not a silent no-op or an in-place update — so migrate compares the index that already exists against what your environment now calls for before it creates anything.

What it does about a disagreement depends on whether the number was set or defaulted:

BLOGPANEL_AUDIT_RETENTION_DAYS Existing TTL index What migrate does
set to anything none Creates it.
set to anything agrees Nothing. A true no-op.
set to anything disagrees Drops the stale index and creates the new one — narrowing, widening, or at 0 dropping it and leaving expiry off.
unset none Creates it at the 90-day default.
unset agrees Nothing.
unset disagrees Nothing — the stored index stands. Warns, naming the window it kept, and exits 0.

Only an explicitly set value ever changes an index that already exists. It refuses in both directions: an unset variable leaves a stored 7-day window alone exactly as it leaves a stored 1825-day one. A default is not an instruction either way.

The setting is sticky, and that is deliberate. Once you have set BLOGPANEL_AUDIT_RETENTION_DAYS and migrated, unsetting it does not take you back to 90 days. To return to the default you must set BLOGPANEL_AUDIT_RETENTION_DAYS=90 explicitly.

The reason is the case where the alternative destroys data. migrate runs in more places than the one where you configured retention: a CI step, a release container, a colleague's laptop, a docker run that forgot its --env-file. If a run that simply did not carry the variable were allowed to reconcile the index to the default, an operator who chose a five-year window would lose four years and nine months of audit history — deleted by MongoDB's TTL monitor within about a minute of that run finishing, silently, with exit code 0 and nothing to restore from. An absent environment variable is not a request to change anything; it is the absence of a request, and migrate treats it as one.

That run prints:

  ⚠ Audit TTL index left unchanged — refusing to apply a default nobody set.
    The index already on nbk_audit keeps entries for 1825 day(s).
    BLOGPANEL_AUDIT_RETENTION_DAYS is not set in this environment, so this run resolved the
    built-in 90-day default — a fallback, not an instruction.
    Applying it would have permanently deleted every audit entry older than 90
    day(s), within about a minute, as soon as MongoDB's TTL monitor ran.
    Set BLOGPANEL_AUDIT_RETENTION_DAYS explicitly wherever this command runs and try again —
    to the value already stored to keep it, 0 to keep every entry forever, or any other
    number to change the window deliberately.
  ✓ Audit TTL index unchanged (retention stays at 1825 day(s))

The practical consequence: set the variable everywhere migrate runs, not only where the app runs. If you cannot, the worst case is now a warning and an unchanged index rather than a deleted audit trail.

If you have audit: { retentionDays: … } in next-blogpanel.config.ts, remove it. It was never read by anything: earlier 1.2.0 development builds documented that key, and migrate silently applied the 90-day default regardless of what it said. migrate now warns when it finds a config file mentioning retentionDays while BLOGPANEL_AUDIT_RETENTION_DAYS is unset, but the key itself no longer exists in BlogPanelUserConfig and setting it does nothing.

CLI

npx next-blogpanel init      # scaffold into your Next.js project
npx next-blogpanel migrate   # create indexes, upgrade a pre-1.0 media schema
npx next-blogpanel seed      # insert three example categories and one post
npx next-blogpanel health    # check MongoDB, R2 and environment variables

The CLI reads .env.local and then .env from the current directory, with the same precedence Next.js uses, and never overrides a variable already present in the process environment.

It does not read next-blogpanel.config.ts. Each command is its own Node process, and loading that file would need a TypeScript transpiler the package does not ship plus the willingness to execute your config — imports of app modules, hooks, and all — inside the CLI. Everything the CLI needs therefore comes from flags and BLOGPANEL_* environment variables: connection details for migrate/seed/health, path flags for init, and BLOGPANEL_AUDIT_RETENTION_DAYS for the audit TTL index.

init flags

Flag Default
--blog-path <path> /blog
--admin-path <path> /admin/blog
--api-path <path> /api/blog
--no-example accepted, currently has no effect — use seed to add example content

init requires a package.json with next as a dependency and an app/ or src/app/ directory. It writes into whichever of those two exists.

health output

Reports MongoDB connectivity, R2 connectivity, and whether the required environment variables parse. R2 reports ○ Not configured (optional) rather than failing when its variables are absent — that is the normal state for a GridFS install.

What init scaffolds

With the default paths:

app/
├── next-blogpanel.setup.ts         # installs next-blogpanel.config.ts at runtime
├── robots.txt/route.ts             # AI-crawler-aware crawl rules
├── llms.txt/route.ts               # llms.txt convention summary of published posts
├── llms-full.txt/route.ts          # every published post's full body, as markdown
├── blog/
│   ├── layout.tsx                  # imports next-blogpanel/styles/blog.css
│   ├── page.tsx                    # list, search, categories, pagination
│   ├── [slug]/page.tsx             # post page with generateMetadata
│   └── category/[slug]/page.tsx    # category filter page
├── admin/blog/
│   ├── layout.tsx                  # AdminLayout, wired with all three paths
│   ├── page.tsx                    # Dashboard
│   ├── posts/page.tsx              # PostList
│   ├── new/page.tsx                # PostEditor
│   ├── [id]/edit/page.tsx          # PostEditor with postId
│   ├── media/page.tsx              # MediaLibrary
│   ├── categories/page.tsx         # CategoryManager
│   ├── redirects/page.tsx          # RedirectManager
│   ├── settings/page.tsx           # SettingsPage
│   └── audit/page.tsx              # AuditLog
└── api/blog/
    ├── posts/route.ts
    ├── media/route.ts
    ├── media/file/[...key]/route.ts   # public GridFS image streaming
    ├── categories/route.ts
    ├── settings/route.ts
    ├── tokens/route.ts
    ├── audit/route.ts
    ├── redirects/route.ts
    ├── sitemap.xml/route.ts
    ├── rss.xml/route.ts
    ├── indexnow.txt/route.ts       # serves the IndexNow keyLocation file
    └── og/[slug]/route.ts          # generated Open Graph card

Plus next-blogpanel.config.ts, .env.local.example and middleware.ts at the project root — the last of those sits outside app/ entirely and has its own section: see Redirects and middleware.ts.

Every API route file is a one-line re-export plus the setup import, and the admin pages are near enough. The blog pages are full source built from atomic components, so they are the ones to edit when you want a different layout. Nothing is overwritten: if a file already exists, init logs ⚠ Skipping <path> (already exists) and moves on — middleware.ts and robots.txt/route.ts included, the two most likely to already exist in a project that predates this package.

Keep the import '…/next-blogpanel.setup'; line when you edit a scaffolded file — see Getting the config to the server for what it does.