npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

hazo_blog

v0.5.0

Published

SEO-optimized blogging package: posts, categories, tags, MDX content, and GA4/GSC/Bing-ready SEO.

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_auth

Requires 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 — SQLite
  • db_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.

Readservice.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.

Writeservice.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).

Schemahazo_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 posteditorial renders the featured-card only (no grid below); grid renders one card.
  • 2 postseditorial renders the featured card + 1 grid card below.
  • Active search (?q=)editorial drops the featured card entirely; every matching post (including the newest) renders as a plain grid card, same as grid.
  • 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 editorial layout's featured card leans hard on featured_image being visible (grid's thumbnails always did too, just smaller). If any post's featured_image is a remote, non-same-origin URL, configure images.remotePatterns in your own next.config.js — otherwise next/image refuses 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.

ServercreateBlogImageUploadRoute(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_images installed (optional peer) — 501 IMAGE_PIPELINE_UNAVAILABLE naming the missing package if it isn't.
  • config.media configured (see BlogMediaConfig below) — 501 MEDIA_NOT_CONFIGURED if 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",
  },
};

ClientcreateBlogImageUploader({ 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 | null

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 to

Option 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 — copy node_modules/sql.js/dist/sql-wasm.wasm to your app's public/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 via hazo_ui's MarkdownEditor. This single, whole-dist-directory entry already covers the 0.5.0 dist/ui (BlogAdminPanel) and dist/components/layouts (grid/editorial index layouts) output — no extra @source line needed.
  • Remote images: if any post's featured_image is a non-same-origin URL, add it to images.remotePatterns — required for next/image to render it, and far more visible now that the editorial layout'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", // default

See 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 subheadings

Options: minWords (default 300), titleLengthLimit (default 60, suffix-aware), titleSuffix, descriptionRange (default [150, 160]).

License

MIT