Components
Three groups: two page-level components that render a whole route, eleven atomic components for building your own layout, and the admin panel.
Most of them are client components; a handful — BlogCard, Pagination, AuthorCard, BreadcrumbNav, CategoryList, and both page-level components (BlogListPage, BlogPostPage) — have no 'use client' directive of their own and render fine from either a Server or Client Component tree. Every one accepts a className that is appended to its root element.
Page-level components
init scaffolds blog pages out of atomic components, so these two are opt-in. Use them when you want a complete page in one line and only need to customise around the edges.
BlogPostPage
import { BlogPostPage } from 'next-blogpanel/components';Renders a full post: breadcrumbs, header, cover image, body, table of contents, share buttons, author card, related posts, reading progress bar, and JSON-LD for BlogPosting, BreadcrumbList and FAQPage.
| Prop | Type | Default | Description |
|---|---|---|---|
post |
object | required | A post object, e.g. from getPostBySlug |
relatedPosts |
object[] | [] |
Shown at the bottom |
showTOC |
boolean | true |
|
tocPosition |
'sidebar' | 'top' | 'none' |
'sidebar' |
|
showAuthor |
boolean | true |
|
showRelatedPosts |
boolean | true |
|
showShareButtons |
boolean | true |
|
showReadingProgress |
boolean | true |
|
basePath |
string | '/blog' |
Blog URL prefix, used for every link the page builds |
siteUrl |
string | '' |
Site origin, e.g. https://example.com. Only used to absolutise the JSON-LD URLs |
siteName |
string | 'Blog' |
Publication name for the JSON-LD isPartOf node. Pass BLOGPANEL_SITE_NAME |
categories |
{ name, slug }[] |
— | The blog's categories, for slug → name labels. Pass listCategories() |
className |
string | '' |
|
slots |
BlogPostSlots |
— |
The BlogPosting links to its parent blog with isPartOf — a Blog node whose @id is siteUrl + basePath and whose name is siteName. That is the property that tells search engines and AI crawlers the post belongs to a publication rather than being a loose page. It is emitted by generateStructuredData() too, from the same builder, so the two paths cannot drift. Without a siteUrl the property is omitted rather than emitted with a relative @id.
Pass siteName alongside siteUrl. Both come from env vars this component cannot read itself, and generateStructuredData() names the same blog from BLOGPANEL_SITE_NAME — so if you use both paths and only configure one of them, you publish two Blog nodes with the same @id and different names:
<BlogPostPage
post={post}
basePath="/blog"
siteUrl={process.env.BLOGPANEL_SITE_URL}
siteName={process.env.BLOGPANEL_SITE_NAME}
categories={categories}
/>Omitting it falls back to "Blog" — the same default BLOGPANEL_SITE_NAME has, so the two paths still agree — and not to the breadcrumb label derived from basePath, which would not.
Pass siteUrl if you want the emitted JSON-LD to be valid for crawlers. BlogPostPage has no 'use client' directive of its own — it's a Server Component — but it's exported from the same next-blogpanel/components barrel as client components like BlogSearch and ShareButtons, and this package doesn't control which context a consuming app renders it from. Reading BLOGPANEL_SITE_URL internally would break the moment it's rendered from a client module graph, since server-only variables are never inlined into the browser bundle — so it's a prop instead. The default GridFS image URL is also root-relative. Without siteUrl the image property is omitted rather than emitted as a path nothing can resolve, and @id/breadcrumb items stay relative:
<BlogPostPage post={post} basePath="/blog" siteUrl={process.env.BLOGPANEL_SITE_URL} />TOC positions: 'sidebar' is sticky and appears at 1024px and wider, 'top' is inline above the body, 'none' renders nothing. The TOC is only rendered when the post has more than two H2/H3/H4 headings, whatever the setting.
Breadcrumbs label the blog level from the last segment of basePath — /articles produces "Articles", not "Blog".
interface BlogPostSlots {
header?: React.ReactNode; // above the article
footer?: React.ReactNode; // below the article
beforeContent?: React.ReactNode; // between cover image and body
afterContent?: React.ReactNode; // after body, before tags
}<BlogPostPage
post={post}
relatedPosts={relatedPosts}
basePath="/blog"
slots={{
header: <SiteHeader />,
footer: <SiteFooter />,
afterContent: <NewsletterSignup />,
}}
/>Post objects come out of MongoDB with ObjectId and Date values, which cannot cross the server/client boundary. Serialise before passing them down — the scaffolded pages use JSON.parse(JSON.stringify(post)).
BlogListPage
import { BlogListPage } from 'next-blogpanel/components';| Prop | Type | Default | Description |
|---|---|---|---|
posts |
object[] | required | |
total |
number | required | Total matching posts, for pagination |
page |
number | 1 |
|
postsPerPage |
number | 10 |
|
categories |
object[] | [] |
The blog's categories. Feeds the sidebar and the card badge labels — pass it even with showCategories={false} |
activeCategory |
string | — | Currently filtered category slug |
showCategories |
boolean | true |
|
hideEmptyCategories |
boolean | false |
Passed to CategoryList as hideEmpty |
showSearch |
boolean | true |
|
layout |
'grid' | 'list' | 'magazine' |
'grid' |
|
basePath |
string | '/blog' |
|
apiPath |
string | '/api/blog' |
Used by the search box |
className |
string | '' |
|
slots |
BlogListSlots |
— |
interface BlogListSlots {
header?: React.ReactNode;
footer?: React.ReactNode;
beforePosts?: React.ReactNode; // between search bar and grid
afterPosts?: React.ReactNode; // after grid, before footer
sidebar?: React.ReactNode; // replaces the default category sidebar
renderCard?: (post: any) => React.ReactNode;
}<BlogListPage
posts={posts}
total={total}
basePath="/blog"
apiPath="/api/blog"
slots={{
header: <h1>Our Blog</h1>,
renderCard: (post) => <MyCard key={post.slug} post={post} />,
sidebar: <NewsletterSignup />,
}}
/>Atomic components
import {
BlogCard, BlogSearch, TableOfContents, ShareButtons, ReadingProgressBar,
Pagination, AuthorCard, BreadcrumbNav, CategoryList, TagCloud, CodeBlock,
} from 'next-blogpanel/components';| Component | Props |
|---|---|
BlogCard |
post, layout ('vertical' | 'horizontal'), basePath, categories |
BlogSearch |
apiPath ('/api/blog'), basePath, placeholder, onSearch |
TableOfContents |
headings: { id, text, level }[] |
ShareButtons |
url, title |
ReadingProgressBar |
— |
Pagination |
currentPage, totalPages, basePath, category |
AuthorCard |
author: { name, avatar?, bio?, url? } |
BreadcrumbNav |
items: { label, href? }[] |
CategoryList |
categories, activeCategory, basePath, hideEmpty |
TagCloud |
tags: { name, count }[], basePath |
CodeBlock |
code, language ('plaintext'), filename |
Notes:
BlogSearchqueries${apiPath}/posts?q=…&limit=5&status=publishedas you type. Give itapiPathif yours is not/api/blog.TableOfContentstracks the active heading with anIntersectionObserver. It takes an already-extracted heading list, not the post.TagCloudsizes tags by relative count.CodeBlockhighlights with Shiki and includes a copy button.
Category labels
post.categories holds category slugs — see api.md. Every component that renders a category (BlogCard, BlogPostPage, and the scaffolded pages) therefore takes an optional categories catalogue and uses it to render the category's name:
import { listCategories } from 'next-blogpanel/lib';
const categories = await listCategories();
<BlogCard post={post} categories={categories} /> // "AI Search & Discovery"
<BlogCard post={post} /> // "Ai Search Discovery"The prop is optional and every component still renders standalone without it. Without the catalogue a slug-shaped value is humanised (ai-search-discovery → "Ai Search Discovery") and anything else is rendered verbatim, so a stored name — pre-migration data, or a value that matched no category — is never mangled. Pass the catalogue when you have it: only it can recover "AI Search & Discovery" from ai-search-discovery. The scaffolded pages do.
The same lookup is exported for your own components:
import { categoryLabel } from 'next-blogpanel/lib';
categoryLabel('ai-search-discovery', categories); // 'AI Search & Discovery'
categoryLabel('ai-search-discovery'); // 'Ai Search Discovery'CategoryList and hideEmpty
<CategoryList categories={categories} activeCategory={slug} hideEmpty />hideEmpty defaults to false, so the default is unchanged: whatever array you pass is what gets rendered. With it set, categories with no published posts are left out — except two cases, both deliberate:
- the active category is always shown, even at zero. It is the filter the reader is currently on, and dropping it would make the sidebar disagree with the page.
- a category whose
postCountis missing entirely is kept. "Unknown" must not read as "empty" and silently blank the list.
If it filters everything out, the component renders nothing at all, as it already did for an empty array.
postCount counts published posts, and is maintained on every post write and recomputed by next-blogpanel migrate.
Admin components
import {
AdminLayout, Dashboard, PostList, PostEditor, MediaLibrary,
CategoryManager, SettingsPage, SEOPanel, AuditLog,
useAdminApi, setApiBase, setBasePath, getBasePath,
} from 'next-blogpanel/admin';AdminLayout
Wraps every admin page with the sidebar and the authentication gate.
| Prop | Type | Default | Description |
|---|---|---|---|
children |
ReactNode | required | |
apiKey |
string | — | Skips the login prompt. Still validated against the server. |
apiPath |
string | '/api/blog' |
Prefix for every admin API call |
adminPath |
string | '/admin/blog' |
Prefix for sidebar nav links |
basePath |
string | '/blog' |
Public blog prefix, used for "View" links and the SEO preview |
If you changed any path, pass it here. The scaffolded layout.tsx already does:
// app/admin/blog/layout.tsx
import { AdminLayout } from 'next-blogpanel/admin';
import 'next-blogpanel/styles/admin.css';
export default function AdminBlogLayout({ children }: { children: React.ReactNode }) {
return (
<AdminLayout adminPath="/admin/blog" apiPath="/api/blog" basePath="/blog">
{children}
</AdminLayout>
);
}AdminLayout pushes those three values into module-level state that the other admin components read, which is why the child pages take no props of their own.
Authentication
On first visit the panel prompts for BLOGPANEL_API_KEY and validates it with GET ${apiPath}/settings before accepting it. A valid key is kept in sessionStorage under blogpanel_api_key and re-validated on every page load; an invalid or revoked key is cleared and you are asked again. Signing out removes the key.
The key never leaves the browser except as a Bearer header, and sessionStorage means it is gone when the tab closes.
The pages
| Component | What it does |
|---|---|
Dashboard |
Total, published and draft counts, plus recent posts |
PostList |
All posts with status badges, search, bulk actions, and "View" links built from basePath |
PostEditor |
TipTap editor, cover image with media picker, SEO panel, author, categories, tags, scheduling, revision history |
MediaLibrary |
Upload and delete, with size, dimensions and upload date |
CategoryManager |
Name, slug, description |
RedirectManager |
Table of redirects (from → to, status code, source, who, when) with delete, plus a create form for manual ones |
SettingsPage |
Site name, site URL, posts per page, API token management, and Crawlers & AI (per-bot allow/block, llms.txt summary, OG card colors, IndexNow switch) |
SEOPanel |
Meta title, description, focus keyword, and the 24-check score — 17 SEO checks plus 7 AEO checks under their own heading. Embedded in PostEditor. |
AuditLog |
Every recorded mutation of posts, categories, media, settings, tokens and redirects — who, what, when |
RedirectManager
import { RedirectManager } from 'next-blogpanel/admin';The admin surface for src/api/redirects.ts — a table (From → To, status code, source, who created it, when) with a delete button per row, plus a create form (from, to, 301/302) above it. Always reads and writes through useAdminApi, so it gets the full authenticated record shape (_id, source, createdBy, createdAt) — see api.md — Redirects for how that differs from the public manifest the same route also serves.
An auto-created redirect — one made as a side effect of changing a published post's slug — shows a gray slug change badge in the Source column; a manually created one shows plain "manual" text. Deleting asks for confirmation first (Anything still linking to the old URL will start 404ing.), since removing a redirect is the one place in this release where a mistake is silently unrecoverable — the old URL simply 404s from that point on.
A hygiene refusal (SELF_REDIRECT, SHADOWS_LIVE_POST, or the equivalent for any rule in api.md — Hygiene rules) comes back from the API as a 400 and is shown verbatim in an error banner above the create form — it is not attached to a specific field, since the message already names which of from/to is the problem.
On an upgrade you have to add the page and its route yourself, the same trap AuditLog shipped with in 1.2.0: AdminLayout's sidebar links to Redirects unconditionally, but app/admin/blog/redirects/page.tsx and app/api/blog/redirects/route.ts are init scaffolding, not part of the package. A project that upgrades to 1.3.0 without re-scaffolding gets a nav link straight to a 404 until both are added. See api.md — Upgrading to 1.3.0.
SettingsPage
Also where scoped API tokens are generated and revoked. The "Generate New Token" form has a Role selector (admin / editor / author) alongside the name field, defaulting to author — the least-privileged option, so provisioning an admin token requires deliberately choosing it rather than falling out of an unrelated default. The token list shows each existing token's role next to its name. Tokens are shown in plaintext once, at creation, and only their hash is stored.
(Earlier 1.2.0 pre-release builds shipped this form without the role selector at all — POST /tokens requires role in the request body with no default, so every "Generate New Token" click failed with a 400 VALIDATION_ERROR. Fixed before release; see tests/admin/SettingsPage.test.tsx for the regression coverage.)
Also renders the Crawlers & AI section: an allow/block selector per bot in KNOWN_AI_CRAWLERS, the llms.txt summary textarea, the two OG card color pickers (ogBackground/ogAccent), and the IndexNow on/off switch — the admin-panel side of the fields in configuration.md — Crawlers & AI. It binds into the same settings object every other section on the page reads and writes, so one "Save Settings" click persists it alongside everything else; the per-bot selector is the one field that doesn't write through the normal update() path, since crawlerPolicy stores only overrides — choosing "Allow" for a bot deletes its key rather than writing 'allow' into it.
SEOPanel
import { SEOPanel } from 'next-blogpanel/admin';The SERP preview and the 17 original SEO checks are unchanged from 1.2.0. Below them, when the caller passes checks (the array calculateSEOScore(post).checks; today's PostEditor does), a second group renders under its own heading, "AEO — will AI engines cite this?" — every check whose id starts with aeo-, kept visually and structurally separate from the SEO checks above rather than interleaved with them. Passing no checks prop (or omitting checks entirely) renders no checks section at all, exactly as SEOPanel behaved before this release — nothing about the panel's other fields changes either way.
The seven AEO checks: an answer-first opening paragraph (≤ 60 words), at least one question-phrased H2, a FAQ or How-To block present, at least one list or table, a named author, visibility to llms.txt (fails outright if noIndex would hide the post — see api.md — llms.txt and llms-full.txt), and freshness (warns past 12 months since the last update). Same SEOCheck shape as every existing check — { id, status, message } — so a caller reading checks for its own UI does not need to know AEO checks exist as a distinct concept.
AuditLog
import { AuditLog } from 'next-blogpanel/admin';Renders nbk_audit as a filterable, paginated table: time, principal ("Master key" or "<name> (<role>)"), action, resource (linked back to the post/category/media/settings/token it names, where a link makes sense), and the summary string. Filters by principalId and resourceType, 20 entries per page. Reads GET ${apiPath}/audit, which requires audit:read — admin and the master key only.
The action column is a superset of the permission names: an author editing a post they own shows as posts:update:own, which is not a grantable permission and does not appear in the roles table — see api.md.
On an upgrade you have to add the page and its route yourself. app/admin/blog/audit/page.tsx and app/api/blog/audit/route.ts are new files in the init scaffold; a project that upgraded from 1.1.x has neither, and AdminLayout links to the page regardless — so the nav item leads to a 404 until you add them. Both files and the exact steps are in api.md — Upgrading to 1.2.0.
Known limitation: the nav item is shown to every role, not just admin. AdminLayout's sidebar lists "Audit Log" unconditionally alongside every other page. An editor or author who clicks it reaches the page and gets a 403 from the API rather than never seeing the link at all. This is because the admin panel currently has no way to learn the signed-in principal's role: it authenticates with a bearer key kept in sessionStorage, validated on each load with a bare GET /settings whose response is only checked for res.ok, and no route today reports the calling principal's role back to the client. Closing this properly means adding a "who am I" response somewhere the panel can read on login — not done as part of this release. It is a real, if minor, rough edge: don't be surprised if a non-admin token sees the link and hits a permission wall.
Editor
import { BlogEditor, type BlogEditorProps } from 'next-blogpanel/editor';| Prop | Type | Default |
|---|---|---|
content |
Record<string, unknown> |
— |
onChange |
(content) => void |
— |
onSave |
(content) => void |
— |
uploadImage |
(file: File) => Promise<{ url, alt? }> |
— |
onBrowseMedia |
() => Promise<{ url, alt? } | null> |
— |
placeholder |
string | 'Start writing your post… Type "/" for commands' |
autosaveInterval |
number | 30000 |
Passing onBrowseMedia swaps the Image slash command for a Media Library one. Omitting uploadImage falls back to a throwaway blob: URL with a console warning — fine for a scratch page, useless for anything persisted. See api.md for a working handler.
The How-To block
import { HowTo, HowToTitle, HowToStep } from 'next-blogpanel/editor';New alongside FAQ, and built the same way as a TipTap node: a title plus one or more ordered steps, added to the document with the How-To / command (see api.md — Editor slash commands), which inserts a title and a single starter step. Whenever a post contains one, three things follow automatically, with nothing further to configure: the HTML render emits itemscope itemtype="https://schema.org/HowTo" microdata around it, llms-full.txt renders it as an ordered list under its title, and generateHowToStructuredData(post) emits a HowTo JSON-LD node — the same "present in the content → present in the structured data" relationship the FAQ block already has. BlogEditor registers it unconditionally. There is currently no way to take it away from authors through configuration: editor.blocks in next-blogpanel.config.ts is informational — nothing reads it (see configuration.md) — so removing howTo from that list changes nothing about what the editor offers.
Stylesheets
import 'next-blogpanel/styles/blog.css'; // app/blog/layout.tsx
import 'next-blogpanel/styles/admin.css'; // app/admin/blog/layout.tsx| Stylesheet | Import in | Contents |
|---|---|---|
styles/blog.css |
Blog layout | Blog list, post page, TOC, cards, pagination. Pulls in globals.css and prose.css. |
styles/admin.css |
Admin layout | Admin panel, dashboard, forms, tables. Pulls in globals.css and editor.css. |
styles/globals.css |
Root layout, optional | CSS variables, buttons, forms, utilities |
styles/prose.css |
— | Post body typography |
styles/editor.css |
— | TipTap editor |
The scaffolded layouts already import the first two, and those two pull in the other three. Import globals.css yourself only when you use atomic components on a page that loads neither.
Every class is prefixed nbp-, every custom property --nbp-. See theming.md.