hazo_blog
v0.5.0
Published
SEO-optimized blogging package: posts, categories, tags, MDX content, and GA4/GSC/Bing-ready SEO.
Maintainers
Readme
hazo_blog
SEO-first blogging for Next.js App Router apps: posts, categories, tags, MDX
content, and GA4 / Google Search Console / Bing-ready output (BlogPosting +
BreadcrumbList + FAQPage JSON-LD, canonical, OG, sitemap, RSS). The package owns
the page layout and all SEO; the host customizes via Tailwind theme tokens
and a single BlogConfig. The /blog index ships two selectable layouts (grid
and editorial — see Layouts), and the admin surface can be mounted
either as sealed pages or embedded as one framework-agnostic panel component
(hazo_blog/ui's BlogAdminPanel — see
Option C).
Install
npm install hazo_blog
# peers:
npm install hazo_core hazo_connect hazo_api hazo_files hazo_ui react react-dom next
# optional peers: hazo_images (AI images), hazo_jobs (scheduled publish), hazo_authRequires hazo_ui >= 3.2.0 (provides the MarkdownEditor used by the admin form).
1. Database
Apply the schema for your engine (also runnable through your migration runner):
db_setup_sqlite.sql— SQLitedb_setup_postgres.sql— PostgreSQL
Tables: hazo_blog_posts, hazo_blog_categories, hazo_blog_tags,
hazo_blog_post_tags (all carry a nullable scope_id for optional
multi-tenancy).
2. Define your BlogConfig
// lib/blog-config.ts
import type { BlogConfig } from "hazo_blog";
export const blogConfig: BlogConfig = {
siteName: "Acme",
baseUrl: "https://acme.com",
basePath: "/blog", // default "/blog"
logoUrl: "https://acme.com/logo.png",
defaultOgImage: "https://acme.com/og.png",
author: { name: "Acme Team", url: "https://acme.com/about" },
getHazoConnect: () => myHazoConnectAdapter, // SQLite or Postgres
// Only needed if you mount createBlogManageRoutes (step 4's `manage`
// export) — `getHazoConnect` above is a hazo_connect CRUD adapter, a
// DIFFERENT shape than the raw-SQL adapter hazo_api's API-key validation
// needs, so most hosts must supply this explicitly rather than relying on
// the (rarely-satisfiable) getHazoConnect fallback. Build one with
// hazo_api's `createApiKeyService`/`createPostgrestApiKeyService`:
// apiKeyService: createPostgrestApiKeyService({ postgrest_url, postgrest_api_key }),
authorize: (req) => checkAdminPermission(req), // wire to hazo_auth
// resolveScope: (req) => orgIdFrom(req), // multi-tenant only
// getAuthor: (id) => lookupAuthor(id), // per-post authors
// onAnalyticsEvent: (name, params) => gtag("event", name, params),
revalidateSeconds: 3600,
// Admin URL overrides (omit to use defaults below):
// adminBasePath: "/admin/blog", // where admin UI pages are mounted
// adminApiBasePath: "/api/admin/blog", // where admin API routes are mounted
// searchApiPath: "/api/blog/search", // where search API route is mounted
};3. Mount the sealed pages
The package owns the page implementations; you write tiny files that pass config.
revalidate and dynamicParams must be static literals (a Next requirement),
and default can't be destructured (reserved word):
// app/blog/page.tsx
import { createBlogIndexPage } from "hazo_blog/next";
import { blogConfig } from "@/lib/blog-config";
const page = createBlogIndexPage(blogConfig);
export default page.default;
export const generateMetadata = page.generateMetadata;
export const revalidate = 3600;// app/blog/[slug]/page.tsx
import { createBlogPostPage } from "hazo_blog/next";
import { blogConfig } from "@/lib/blog-config";
const page = createBlogPostPage(blogConfig);
export default page.default;
export const generateMetadata = page.generateMetadata;
export const generateStaticParams = page.generateStaticParams;
export const revalidate = 3600;
export const dynamicParams = true;// app/blog/tag/[tag]/page.tsx
import { createBlogTagPage } from "hazo_blog/next";
import { blogConfig } from "@/lib/blog-config";
const page = createBlogTagPage(blogConfig);
export default page.default;
export const generateMetadata = page.generateMetadata;
export const revalidate = 3600;4. Mount the API routes
Route-handler factories are exported from the main hazo_blog entry (they are
React-free, so they don't pull the React page components into your API bundles):
// app/api/blog/search/route.ts
import { createBlogSearchRoute } from "hazo_blog";
import { blogConfig } from "@/lib/blog-config";
export const GET = createBlogSearchRoute(blogConfig);
// app/api/blog/feed/route.ts → RSS
export const GET = createBlogFeedRoute(blogConfig);
// app/api/admin/blog/route.ts (cookie/session, guarded by authorize)
const admin = createBlogAdminRoutes(blogConfig);
export const GET = admin.collection.GET;
export const POST = admin.collection.POST;
// app/api/admin/blog/[id]/route.ts
export const PUT = admin.item.PUT;
export const DELETE = admin.item.DELETE;
// app/api/blog/manage/route.ts (programmatic, hazo_api keys; scopes blog:read / blog:write)
const manage = createBlogManageRoutes(blogConfig);
export const { GET, POST, PATCH } = manage;5. Sitemap & robots
sitemap.xml / robots.txt are site-level singletons — merge the blog's
contributions into your root files:
// app/sitemap.ts
import { createBlogService, getBlogSitemapEntries, resolveConfig } from "hazo_blog";
import { blogConfig } from "@/lib/blog-config";
export default async function sitemap() {
const service = createBlogService(blogConfig);
const { posts } = await service.listPosts({ perPage: 1000 });
return [/* ...your routes */, ...getBlogSitemapEntries(resolveConfig(blogConfig), posts)];
}Settings
hazo_blog owns a small settings table (hazo_blog_settings) for host-configurable
display/behavior settings. As of 0.5.0 it holds one setting — indexLayout (see
Layouts) — with room for more to be added later without a config or
DDL churn for hosts already wired up.
Read — service.getSettings(req?) never throws. It reads the persisted row
and falls back to { indexLayout: config.defaultIndexLayout ?? "grid" } if the
table is missing or the read errors — logging one warning per process, not per
request, so a fleet of requests hitting an un-migrated table doesn't spam logs.
Write — service.updateSettings(patch, req?) merges patch into the current
settings and persists it. Throws BlogSettingsUnavailableError if the table
can't be written to (e.g. not migrated yet) — the route factory below turns
this into a 503.
HTTP surface — mount createBlogSettingsRoutes(config) at
config.settingsApiPath (default ${adminApiBasePath}/settings):
// app/api/admin/blog/settings/route.ts
import { createBlogSettingsRoutes } from "hazo_blog";
import { blogConfig } from "@/lib/blog-config";
const routes = createBlogSettingsRoutes(blogConfig);
export const GET = routes.GET;
export const PUT = routes.PUT;Both GET and PUT are guarded by config.authorize, same as the other admin
routes. GET returns:
{
settings: BlogSettings; // { indexLayout: "grid" | "editorial" }
defaults: { indexLayout?: BlogIndexLayout }; // echoes config.defaultIndexLayout
layouts: typeof BLOG_INDEX_LAYOUT_META; // id/label/description per layout
persisted: boolean; // false = table not migrated, read-only
}PUT accepts a Partial<BlogSettings> body and returns { settings } on
success, or 503 SETTINGS_UNAVAILABLE when the table can't be written to (the
same BlogSettingsUnavailableError from getSettings/updateSettings above).
Schema — hazo_blog_settings (dual-dialect, defined in db_setup_sqlite.sql
/ db_setup_postgres.sql alongside the other four tables): one row per
(scope_id, setting_key) with a JSON payload column. Apply it the same way as
the rest of this package's DDL — through your migration runner on SQLite, or a
manual psql -f step on Postgres, per your app's own DDL convention.
onSettingsChanged — fires after a successful updateSettings write,
inside its own try/catch so a host revalidate failure can't fail the settings
save itself:
onSettingsChanged: () => {
try {
revalidatePath("/blog");
} catch {
// No-op outside a request/render context (e.g. called from a script).
}
},This matters because /blog is typically ISR-cached (revalidate = 3600 from
step 3) — without a revalidate hook, switching layouts via the admin panel
won't be visible on the public index until the next natural ISR window.
Layouts
config.defaultIndexLayout (and the runtime override in Settings)
picks which component renders the /blog index — BLOG_INDEX_LAYOUT_META
(exported from hazo_blog and hazo_blog/lib) describes both:
| Layout | Looks like | Use it when |
|---|---|---|
| grid (default) | Classic responsive card grid — one flat sm:grid-cols-2 lg:grid-cols-3 layout for every post. Byte-identical to the pre-0.5.0 index markup. | You want the plainest, safest option, or your posts don't reliably carry a featured_image. |
| editorial | Big title + subtitle, pill search, a hairline rule, a full-width featured split card for the newest post, then a staggered 3-column grid below. | You want a more magazine-like landing page and most posts have a featured_image. |
Degradation behavior (same for both layouts unless noted):
- 0 posts — empty state.
- 1 post —
editorialrenders the featured-card only (no grid below);gridrenders one card. - 2 posts —
editorialrenders the featured card + 1 grid card below. - Active search (
?q=) —editorialdrops the featured card entirely; every matching post (including the newest) renders as a plain grid card, same asgrid. featured_image: null— both the featured card and grid cards render a gradient + icon placeholder instead of an image block. No broken-image state either layout.- Mobile — single column;
editorial's featured card stacks image-over-text instead of side-by-side.
Set the default at boot via defaultIndexLayout in BlogConfig (falls back
to "grid" when omitted). Switch it live at runtime via the admin panel's
built-in LayoutSelector (see Option C),
which writes through service.updateSettings({ indexLayout }) to the
hazo_blog_settings table above — no redeploy required, only an ISR revalidate
(wire onSettingsChanged as shown in Settings, or wait out the
natural revalidate window).
Remote images: the
editoriallayout's featured card leans hard onfeatured_imagebeing visible (grid's thumbnails always did too, just smaller). If any post'sfeatured_imageis a remote, non-same-origin URL, configureimages.remotePatternsin your ownnext.config.js— otherwisenext/imagerefuses to render it. See Required Next.js config.
Images
Featured-image and MDX-body-image uploads share one endpoint and one upload policy — there's no separate pipeline for the two.
Server — createBlogImageUploadRoute(config, deps?), mounted at
config.imageUploadApiPath (default ${adminApiBasePath}/images):
// app/api/admin/blog/images/route.ts
import { createBlogImageUploadRoute } from "hazo_blog";
import { blogConfig } from "@/lib/blog-config";
export const POST = createBlogImageUploadRoute(blogConfig);It requires both:
hazo_imagesinstalled (optional peer) —501 IMAGE_PIPELINE_UNAVAILABLEnaming the missing package if it isn't.config.mediaconfigured (seeBlogMediaConfigbelow) —501 MEDIA_NOT_CONFIGUREDif absent.
On success it rewrites the saved file's URL via config.media.toPublicUrl (if
given), or by joining config.media.publicUrlBase with the stored path
otherwise:
interface BlogMediaConfig {
getFileManager: () => BlogFileManager | Promise<BlogFileManager>;
pathPrefix?: string; // default "/hazo-blog"
publicUrlBase?: string;
toPublicUrl?: (saved: { path: string; url: string }) => string;
maxUploadBytes?: number;
allowedTypes?: string[];
processOptions?: Record<string, unknown>;
}BlogFileManager is a structural interface (uploadFile(source, remotePath, options?)) — you
don't need an adapter to satisfy it with hazo_files' FileManager/TrackedFileManager, e.g.:
// lib/blog-config.ts
import { createInitializedFileManager, type FileManager } from "hazo_files";
let fileManagerPromise: Promise<FileManager> | null = null;
function getFileManager(): Promise<FileManager> {
if (!fileManagerPromise) {
fileManagerPromise = createInitializedFileManager({
config: { provider: "local", local: { basePath: "/path/to/public/uploads" } },
});
}
return fileManagerPromise;
}
export const blogConfig: BlogConfig = {
// ...
media: {
getFileManager: () => getFileManager(),
pathPrefix: "/hazo-blog",
publicUrlBase: "/uploads",
},
};Client — createBlogImageUploader({ endpoint, fetchFn?, processOptions? })
(exported from hazo_blog/client) returns a (file: File) => Promise<string | null>
function — never throws, resolves null on any failure. This is exactly what
PostForm's featured-image field and its MarkdownEditor's inline-image button
call under the hood when PostForm is given imageUploadEndpoint:
"use client";
import { createBlogImageUploader } from "hazo_blog/client";
const uploadImage = createBlogImageUploader({ endpoint: "/api/admin/blog/images" });
const url = await uploadImage(file); // string | null6. Admin pages
Option A — Sealed admin pages (recommended)
Drop in the three sealed admin page factories. They render a full admin UI
(post list with status/publish-date badges + edit links, create form, edit form)
and respect your authorize(req) gate via the API routes.
// app/admin/blog/page.tsx — post list
import { createBlogAdminListPage } from "hazo_blog/next";
import { blogConfig } from "@/lib/blog-config";
const page = createBlogAdminListPage(blogConfig);
export default page.default;
export const dynamic = "force-dynamic"; // must be a static literal
// app/admin/blog/new/page.tsx — create form
import { createBlogAdminNewPage } from "hazo_blog/next";
const page = createBlogAdminNewPage(blogConfig);
export default page.default;
export const dynamic = "force-dynamic";
// app/admin/blog/[slug]/edit/page.tsx — edit form
import { createBlogAdminEditPage } from "hazo_blog/next";
const page = createBlogAdminEditPage(blogConfig);
export default page.default;
export const dynamic = "force-dynamic";If your admin API or UI routes live at non-default paths, override in BlogConfig:
adminBasePath: "/admin/blog", // default — where factory pages link to
adminApiBasePath: "/api/admin/blog", // default — where PostForm POSTs toOption B — Custom admin form
Use PostForm directly for a fully custom admin UI:
"use client";
import { PostForm } from "hazo_blog/client";
// pass categories + an endpoint; wire onImageUpload to hazo_files for paste-upload
<PostForm categories={categories} endpoint="/api/admin/blog" onSaved={...} />
// For edit: <PostForm post={existingPost} categories={categories} ... />Option C — embed the panel in your admin shell
hazo_blog/ui exports BlogAdminPanel — a framework-agnostic (no next/*
imports), token-free-styled (raw Tailwind palette classes, not this
package's theme CSS variables) React component. Token-free means it drops into
any host admin shell without that host needing to adopt hazo_theme.
interface BlogAdminPanelProps {
fetchFn: (path: string, init?: RequestInit) => Promise<Response>;
basePath: string; // e.g. "/api/admin/blog"
blogBasePath?: string; // public link base, default "/blog"
settingsPath?: string; // default `${basePath}/settings`
imageUploadPath?: string; // default `${basePath}/images`
className?: string;
}// A "use client" host component (fetchFn is a closure — it can't cross the
// server→client prop boundary unless it's a Server Action, so this has to be
// a client module, not a server component rendering a client child).
"use client";
import { BlogAdminPanel } from "hazo_blog/ui";
export function BlogAdmin() {
return (
<BlogAdminPanel
fetchFn={(path, init) => fetch(path, { credentials: "include", ...init })}
basePath="/api/admin/blog"
/>
);
}It provides: a searchable/filterable post list, create/edit via a built-in
dialog (wraps PostForm), delete, and the layout selector from
Layouts (with a "not migrated, read-only" banner when settings
persistence isn't available — see Settings).
This is the pattern hazo_admin 0.13.2 uses to lazy-mount the panel (a dynamic
import("hazo_blog/ui") behind a .catch() fallback, same shape as its
hazo_jobs/ui integration) — see hazo_admin's own docs/CHANGE_LOG for that
side of the wiring, not this package's concern.
Required Next.js config
// next.config.js
const nextConfig = {
// Transpile next-mdx-remote so Turbopack dedupes a single React copy
// (otherwise MDX prerender throws "React Element from an older version").
transpilePackages: ["hazo_blog", "hazo_ui", "next-mdx-remote"],
// Only if you use hazo_connect's SQLite (better-sqlite3) adapter:
serverExternalPackages: ["better-sqlite3"],
};- hazo_connect SQLite uses
sql.js(WASM) by default — copynode_modules/sql.js/dist/sql-wasm.wasmto your app'spublic/sql-wasm.wasm. - Tailwind v4: add
@source "../node_modules/hazo_blog/dist";to your CSS so the package's classes are compiled. The admin editor's styles load automatically viahazo_ui'sMarkdownEditor. This single, whole-dist-directory entry already covers the 0.5.0dist/ui(BlogAdminPanel) anddist/components/layouts(grid/editorial index layouts) output — no extra@sourceline needed. - Remote images: if any post's
featured_imageis a non-same-origin URL, add it toimages.remotePatterns— required fornext/imageto render it, and far more visible now that theeditoriallayout's featured card leans on it (see Layouts).
Build resilience
BlogContent (≥ 0.3.0) is an async server component that catches MDX compilation errors with
try/catch, so a single malformed post renders a graceful fallback instead of failing the whole
next build. It also pre-sanitizes content with sanitizeMdx before passing it to the compiler.
The sanitizer fixes the common ways raw WordPress HTML trips the MDX/acorn parser: converts HTML
comments → MDX comments (and escapes stray <!), strips <script>/<style> blocks whose bodies
break parsing, self-closes HTML void elements (<img>, <br>, …), and drops string-valued
style="..." attributes (invalid JSX; the object form style={{...}} is left untouched). Fenced
and inline code spans are left byte-for-byte untouched, and the transform is idempotent. The
sanitizer is also available as a standalone export from hazo_blog/lib for custom rendering
pipelines.
Exports
| Entry | Contents |
|---|---|
| hazo_blog | server: createBlogService, createBlogRepository, SEO builders, route-handler factories (React-free), types |
| hazo_blog/client | client components: PostCard, PostHero, AuthorBio, FaqSection, TableOfContents, RelatedPosts, BlogSearch, PostForm, MDX components, trackBlogEvent |
| hazo_blog/next | sealed page factories: public (createBlogIndexPage, createBlogPostPage, createBlogTagPage) + admin (createBlogAdminListPage, createBlogAdminNewPage, createBlogAdminEditPage) + BlogContent + MdxErrorBoundary |
| hazo_blog/lib | pure text utilities: sanitizeMdx, mdxToPlainText, buildExcerpt, slugify, calculateReadingTime, extractToc + validatePost (pre-publish content-quality gate) |
| hazo_blog/seo | buildBlogPostingJsonLd, buildBreadcrumbJsonLd, buildFaqJsonLd, getBlogSitemapEntries, getBlogRobotsRules, getBlogRssXml |
| hazo_blog/config | BlogConfig types + resolveConfig |
| hazo_blog/ui | BlogAdminPanel — framework-agnostic, token-free admin panel component for embedding in a host admin shell (e.g. hazo_admin). No next/*, no server-only imports — pure React + Tailwind. |
Search
BlogSearch (rendered inside createBlogIndexPage) supports:
- Dropdown: appears after 2+ characters with a 250 ms debounce
- Enter key: navigates to
${basePath}?q={term}— the index page renders a filtered grid with a result count and Clear link - Escape: dismisses the dropdown
If your search API lives at a non-default path, set searchApiPath in BlogConfig:
searchApiPath: "/api/blog/search", // defaultSee SETUP_CHECKLIST.md for a step-by-step integration list. A runnable demo
lives in test-app/.
Pre-publish validation
validatePost is a pure, server-safe content-quality gate. Wire it into your
create/update/publish path and reject on !result.ok:
import { validatePost } from "hazo_blog"; // also on the `hazo_blog/lib` subpath
const result = validatePost(
{ title, content, meta_description, excerpt, faq },
{ titleSuffix: " | GoTimer" }, // options are optional
);
if (!result.ok) {
return fail(result.errors); // blocking: thin content, missing meta description, a second <h1>
}
logWarnings(result.warnings); // advisory: meta length, over-long title, missing excerpt/FAQ, no subheadingsOptions: minWords (default 300), titleLengthLimit (default 60, suffix-aware),
titleSuffix, descriptionRange (default [150, 160]).
License
MIT
