hazo_blog
v1.2.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.
Locale variants
Breaking change in 1.0.0 — see the migration note below if you're upgrading from 0.x.
hazo_blog_posts/_categories/_tags each carry a nullable locale column, uniquely constrained per (scope_id, slug, locale) — a NULL locale is your "default" post, and only one default (and one per non-null locale) can exist per slug. Posts also carry translation_of (the id of the post this is a translation of) and indexable (excludes a post from the sitemap and marks its JSON-LD/RSS as non-indexable without unpublishing it).
// Create a translated variant of an existing post
await service.upsertBySlug({
slug: "my-post",
locale: "fr",
translation_of: originalPost.id,
title: "Mon article",
// ...
});
// Fetch a specific locale — returns null (not the default post) when that
// locale doesn't exist, unless you opt into fallback:
const fr = await service.getPost("my-post", { locale: "fr" });
const frOrDefault = await service.getPost("my-post", { locale: "fr", fallbackToDefault: true });
// Walk a post's translation cluster (root + every sibling translation, minus itself)
const translations = await service.getTranslations(post.id);
// => [{ locale, slug, id, indexable }, ...]listPosts()/searchPosts() default to locale-omitted, which returns default-locale posts only — existing single-locale consumers see no behavior change and never double-count translations in a listing. Pass locale explicitly to list/search one locale's posts.
SEO: buildBlogPostingJsonLd sets inLanguage from post.locale || config.defaultLocale || "en"; getBlogSitemapEntries excludes indexable: false posts and adds an alternates: [{ locale, url }] entry per sitemap item when the post carries a .translations array (populate it yourself via getTranslations before calling the sitemap builder — the package doesn't do this join automatically); getBlogRssXml uses config.defaultLocale for the feed <language> and adds a per-item <dc:language> when an item's locale differs from the feed default. generateMetadata on the post page adds alternates.languages (hreflang) automatically from getTranslations.
Migrating from 0.x: getPostBySlug's second parameter is now an options object, not a boolean —
// before (0.x)
await repo.getPostBySlug(slug, true);
// after (1.0.0)
await repo.getPostBySlug(slug, { includeUnpublished: true });— and getAllSlugs() now returns Array<{ slug, locale }> instead of string[]. Existing single-locale data needs no migration: every current row's locale is NULL already, which is the default-locale bucket every existing query path reads from.
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
