hazo_blog
v0.3.3
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.
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
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)];
}6. 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} ... />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.
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 |
| hazo_blog/seo | buildBlogPostingJsonLd, buildBreadcrumbJsonLd, buildFaqJsonLd, getBlogSitemapEntries, getBlogRobotsRules, getBlogRssXml |
| hazo_blog/config | BlogConfig types + resolveConfig |
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/.
License
MIT
