hazo_seo
v0.11.0
Published
Drop-in SEO/AEO/GEO toolkit for a single site: sitemaps, robots, llms.txt, structured data, metadata, redirects, consent, legal pages, audits, and search/analytics collectors.
Maintainers
Readme
hazo_seo
Drop-in SEO/AEO/GEO toolkit for a single site: sitemaps, robots.txt, llms.txt, structured data, metadata, redirects, consent, legal pages, crawl audits, and search/analytics collectors.
Built for Next.js and Express apps. Ships as a set of sub-exports so you only pull in what you use.
Installation
npm install hazo_seo
# Required peer deps (always)
npm install hazo_core hazo_config hazo_logsOptional peer deps (install only the tiers you need):
# Tier-2 DB features (redirects, audit persistence, consent receipts)
npm install hazo_connect
# HTTP route handlers
npm install hazo_apiSub-exports
| Import | Tier | What it does |
|--------|------|--------------|
| hazo_seo/files | 1 | Sitemap builder, robots.txt, AI-crawler presets |
| hazo_seo/metadata | 1 | buildPageMetadata() for Next.js metadata exports |
| hazo_seo/experiments | 1 | defineSeoExperiment() / resolveSeoVariant() — date-window title/meta A/B testing |
| hazo_seo/schema | 1 | stringifyJsonLd() — XSS-safe JSON-LD serialization for <script type="application/ld+json"> |
| hazo_seo/analytics | 1 | GA4 gtag scripts + optional SPA pageviews |
| hazo_seo/consent | 1 | Cookie consent (coming v0.4) |
| hazo_seo/collectors | 2 | GSC/GA4/Bing/AdSense (coming v0.5) |
| hazo_seo/audit | 2 | Crawl audits (coming v0.6) |
| hazo_seo/redirects | 2 | Redirect store + middleware (coming v0.7) |
Tier-1 features work with zero database. Tier-2 features require hazo_connect.
Quick start
robots.txt (Next.js)
// app/robots.ts
import { buildRobotsObject } from 'hazo_seo/files';
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return buildRobotsObject({
baseUrl: 'https://example.com',
aiPreset: 'allow-search-block-training',
disallow: ['/admin/', '/api/'],
sitemaps: ['/sitemap.xml'],
});
}sitemap.xml (Next.js)
// app/sitemap.ts
import { createSitemapRegistry, buildSitemapXml } from 'hazo_seo/files';
const registry = createSitemapRegistry();
registry.register({
name: 'marketing',
fetch: async () => [
{ url: 'https://example.com/', lastModified: '2026-01-01' },
{ url: 'https://example.com/about' },
],
});
export default async function sitemap() {
const { entries } = await registry.collect();
// Return as Next.js MetadataRoute.Sitemap or serve the raw XML
return entries;
}Page metadata (Next.js)
// app/about/page.tsx
import { buildPageMetadata } from 'hazo_seo/metadata';
const SITE = {
baseUrl: 'https://example.com',
locale: 'en',
siteName: 'My Site',
defaultOgImage: 'https://example.com/og.png',
};
// title is composed as "<title> | <siteName>" and emitted as `title.absolute`
// so a root-layout title.template does not re-double the site suffix.
// A pre-existing trailing suffix ("About us | My Site") is stripped first.
export const metadata = buildPageMetadata({
site: SITE,
title: 'About us',
description: 'Learn about our team.',
path: '/about/',
kind: 'website',
}).metadata;
// → metadata.title === { absolute: 'About us | My Site' }API — hazo_seo/files
buildRobots(options)
Generates the text body of a robots.txt file.
import { buildRobots, type RobotsAiPreset } from 'hazo_seo/files';
const { body, warnings } = buildRobots({
baseUrl: 'https://example.com', // used to anchor sitemaps
aiPreset: 'allow-search-block-training', // one of 3 presets
disallow: ['/admin/', '/private/'], // paths for the * agent
sitemaps: ['/sitemap.xml'],
});AI presets (RobotsAiPreset):
'allow-search-block-training'— lets search bots index; blocks AI training crawlers (recommended)'allow-all'— no AI-specific restrictions'block-all-ai'— disallows all known AI crawlers
buildRobotsObject(options)
Same as buildRobots but returns the Next.js MetadataRoute.Robots shape instead of a raw string.
createSitemapRegistry()
Returns a registry where you register named sitemap sources. Sources are collected and de-duplicated at build time.
const registry = createSitemapRegistry();
registry.register({
name: 'blog',
fetch: async () => blogPosts.map(p => ({
url: `https://example.com/blog/${p.slug}`,
lastModified: p.updatedAt, // only included if after the pub date (truthful-lastmod guard)
})),
});
const { entries, errors } = await registry.collect(); // per-source error isolationbuildSitemapXml(entries, options?)
Converts SitemapEntry[] into a <urlset> XML string. Auto-splits into index + part files when entries exceed 50,000 or 50 MB.
import { buildSitemapXml, buildSitemapIndexXml } from 'hazo_seo/files';
const xml = buildSitemapXml(entries); // <urlset>
const idx = buildSitemapIndexXml(partUrls); // <sitemapindex>AI_CRAWLERS
Typed registry of known AI crawler User-agent strings, annotated with purpose ('search' | 'training' | 'both').
import { AI_CRAWLERS, type AiCrawlerPurpose } from 'hazo_seo/files';API — hazo_seo/metadata
buildPageMetadata(options)
Builds a Next.js Metadata object with title, description, canonical URL, Open Graph, Twitter card, and robots directives. Returns { metadata, warnings }.
Title composition: when site.siteName is set, the function appends | <siteName> and emits metadata.title as { absolute: '<composed title>' }. The absolute form tells Next.js to ignore any root-layout title.template, preventing a double suffix. If the input title already ends with a trailing | <siteName> (pipe, en-dash, em-dash, or hyphen separator — case-insensitive), the duplicate is stripped first so the output always contains exactly one suffix. When no siteName is configured, metadata.title is the plain input string (current behaviour preserved).
import { buildPageMetadata } from 'hazo_seo/metadata';
const { metadata, warnings } = buildPageMetadata({
site: {
baseUrl: 'https://example.com',
locale: 'en',
siteName: 'My Site',
defaultOgImage: 'https://example.com/og.png',
},
title: 'Contact us',
description: 'Get in touch with our team.',
path: '/contact/',
kind: 'website', // 'website' | 'article' | 'profile'
noIndex: false, // set true for admin/private pages
ogImage: '/contact-og.png', // override default OG image
experiment: myExperiment, // optional — see `hazo_seo/experiments` below
});
// metadata.title === { absolute: 'Contact us | My Site' }
// openGraph.title === 'Contact us | My Site'
// twitter.title === 'Contact us | My Site'
// warnings includes length violations (title >65 chars, desc >160 chars)
// and a notice when a duplicate suffix was stripped from the inputexperiment (optional): a SeoExperiment from hazo_seo/experiments. When it has an
active phase, the resolved variant's title/description override title/description above,
and the result gets other: { 'seo-variant': '<key>:<variantId>' } plus a variant field
carrying the full resolution. Omitting it — or passing one that's paused or not yet started —
produces byte-identical output to not having the option at all. Description validation (missing
/ <150 / >160 chars) is always run against whichever description actually ends up in the
metadata — the resolved variant's description when an experiment is active, options.description
otherwise — so a variant that supplies its own description doesn't need options.description set
just to avoid a false "missing description" warning.
API — hazo_seo/experiments
Google serves one title/snippet per URL to everyone, so SEO experiments can't use per-user
bucketing like a normal A/B test. Instead, variants rotate over sequential calendar-date
"phases" — every visitor and every crawler sees the same variant on a given day, and impact is
judged phase-over-phase from Search Console CTR. Pure and isomorphic — safe in hazo_seo/client.
defineSeoExperiment(cfg)
Validates a SeoExperiment config and returns it unchanged (a type-inference convenience for
the call site). Throws HazoValidationError (code: 'INVALID_ARGUMENT', all problems batched
into .issues[]) on malformed config.
import { defineSeoExperiment, type SeoExperiment } from 'hazo_seo/experiments';
const chessClockTitle: SeoExperiment = defineSeoExperiment({
key: 'chess-clock-title',
path: '/chess-clock',
variants: [
{ id: 'control', title: 'Chess Clock — Free Online Timer' },
{ id: 'a_instant', title: 'Free Chess Clock — No Signup, Starts Instantly' },
],
schedule: {
startDate: '2026-08-11', // YYYY-MM-DD, UTC
phaseDays: 14, // >= 7
order: ['control', 'a_instant'],
burnInDays: 4, // default 4 — days after a switch excluded from scoring
loop: false, // default false — hold the last variant after the last phase
},
});resolveSeoVariant(exp, now?)
Resolves which variant is live for now (defaults to new Date()). Returns null when the
experiment is status: 'paused', or when now is before schedule.startDate — callers fall
back to their own default title/description in either case.
import { resolveSeoVariant } from 'hazo_seo/experiments';
const resolved = resolveSeoVariant(chessClockTitle);
// resolved?.variant.title, resolved?.variantId, resolved?.phaseIndex,
// resolved?.isBurnIn, resolved?.marker === 'chess-clock-title:a_instant'Plug an experiment straight into buildPageMetadata and it resolves automatically:
import { buildPageMetadata } from 'hazo_seo/metadata';
export const metadata = buildPageMetadata({
site: SITE,
title: 'Chess Clock — Free Online Timer', // fallback when no phase is active
path: '/chess-clock/',
experiment: chessClockTitle,
}).metadata;To also record the live variant in GA4 for engagement analysis, see sendSeoVariantDimension
under hazo_seo/analytics below.
See the test-app's /experiments page for an interactive playground covering every edge case
(pre-start, burn-in, a phase boundary, the post-order hold, and the loop wrap) by driving the
experiment's schedule.startDate relative to today, since buildPageMetadata/resolveSeoVariant
resolve against the real clock and there's no way to inject a fake now through that path.
API — hazo_seo/schema
stringifyJsonLd(value)
Serializes a JSON-LD payload for embedding inside a <script type="application/ld+json"> tag.
JSON.stringify alone does not escape </script> — if the payload contains that sequence (even
from a translated string or user-controlled data), it closes the script tag early and allows
injection. stringifyJsonLd escapes </script> to <\/script> and <!-- to <\!--, keeping the
JSON semantically identical (a JSON parser reads the escaped form back to the original string)
while making it inert as HTML.
import { stringifyJsonLd } from 'hazo_seo/schema';
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Person',
name: person.displayName, // may contain arbitrary user input
};
// In a Server Component:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: stringifyJsonLd(jsonLd) }}
/>API — hazo_seo/analytics
Google Analytics (GA4)
GoogleAnalyticsScripts injects the two GA4 script tags (gtag/js?id=... + an inline
gtag('config', ...) call). Drop it into your root layout, after any consent-mode defaults so
consent fires before GA runs:
// app/layout.tsx
import { GoogleAnalyticsScripts } from 'hazo_seo/analytics';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<GoogleAnalyticsScripts id="G-XXXXXXXXXX" />
</body>
</html>
);
}id is optional — unset, empty, or whitespace-only renders null (a dormant no-op), so the
component can be wired into a layout before a measurement ID is configured. A non-empty but
malformed id still throws (/^G-[A-Z0-9]+$/) — that's the XSS guard, since the ID is
interpolated into an inline <script>.
For SPA (client-side) pageview tracking on App Router route changes, use GoogleAnalytics
instead, with trackRouteChanges opted in:
import { GoogleAnalytics } from 'hazo_seo/analytics';
<GoogleAnalytics id="G-XXXXXXXXXX" trackRouteChanges />⚠️ Double-counting risk:
trackRouteChangesdefaults to false. GA4 enhanced measurement — ON by default on GA4 properties — already fires pageviews on browser history events, so turning both on double-counts pageviews. Only enabletrackRouteChangesif you've turned enhanced measurement's pageview tracking off. (For the same reason,@next/third-parties' ownGoogleAnalyticscomponent doesn't fire route-change pageviews either.)
Other props: nonce (CSP nonce, applied to both script tags), debugMode (adds
{'debug_mode':true} to the config call, for GA4 DebugView), and dataLayerName (override the
global data-layer variable name; validated as a JS identifier).
To send custom events from anywhere in your app:
import { sendGaEvent } from 'hazo_seo/analytics';
sendGaEvent('sign_up', { method: 'google' });sendGaEvent/sendGaPageview/pushToDataLayer never throw — they no-op (return false) when
no data layer exists, so they're safe to call during SSR or before GA's scripts have loaded.
SEO experiment variant tracking
sendSeoVariantDimension(marker, opts?) pushes the live SEO A/B test variant (from
hazo_seo/experiments) to GA4 as an event-scoped seo_variant custom parameter, so on-page
engagement can later be sliced by variant. Search Console CTR remains the primary metric for
judging a title/meta experiment — this is a secondary signal only.
import { sendSeoVariantDimension } from 'hazo_seo/analytics';
import { resolveSeoVariant } from 'hazo_seo/experiments';
const resolved = resolveSeoVariant(chessClockTitle);
if (resolved) sendSeoVariantDimension(resolved.marker); // 'chess-clock-title:a_instant'Like the other send helpers, it never throws — it no-ops (return false) on an empty/non-string
marker or when no data layer exists yet.
Building interactive tool pages (ToolPageShell)
Interactive pages (calculators, timers, converters) commonly wrap their entire body in a client
component that reads dynamic state (e.g. useSearchParams()), which makes the server-rendered
HTML render as an empty Suspense fallback — crawlers and AI fetchers that don't execute JS see
almost no content. ToolPageShell is a Server Component that keeps the static SEO content
structurally separate from the interactive "island":
// app/my-tool/page.tsx — stays a Server Component, no "use client" here
import { ToolPageShell } from 'hazo_seo';
import { MyWidget } from './widget'; // "use client" lives in here instead
export default function Page() {
return (
<ToolPageShell
header={<SiteNav />}
island={<MyWidget />}
islandFallback={<p>Loading…</p>} // required — no silently-empty fallback
content={<article>{/* real prose, FAQ, etc. — renders unconditionally */}</article>}
/>
);
}See design/patterns/interactive-ssr-pages.md for the full pattern write-up, and
test-app/app/tool-good/page.tsx for a working example.
discoverAppRoutes(appDir) — App Router static-route discovery
Walks a Next.js App Router app/ directory and returns every route it can derive purely from the
filesystem — handy for feeding a SitemapSourceRegistry source, or for hazo_seo/testing's
assertServerText/assertSingleH1 route lists, without hand-maintaining them. Node-only
(node:fs), so it's exported from the main hazo_seo entry, not hazo_seo/client.
import { discoverAppRoutes } from 'hazo_seo';
import path from 'node:path';
const { routes, errors } = discoverAppRoutes(path.join(process.cwd(), 'app'));
// routes: ['/', '/about', '/about/team', '/contact', ...] — sorted, deduplicated
registry.register({
name: 'static-pages',
fetch: () => routes.map((r) => ({ url: `https://example.com${r === '/' ? '' : r}` })),
});Recognizes page.tsx, page.ts, page.jsx, and page.js. Route groups ((marketing)) are
walked but stripped from the resulting path, so app/(marketing)/about/page.tsx → /about, not
/(marketing)/about. Excluded entirely (not recursed into):
api/— route handlers (route.ts), not pages- private folders — any leading-underscore name (
_components,_lib, …) - parallel routes (
@slot) — a slot'spage.tsxisn't an independently navigable URL; the matching page is the parent segment's ownpage.tsx - dynamic segments —
[slug],[...all],[[...opt]], and everything nested beneath them, since concrete values can't be enumerated from the filesystem. Register those routes yourself, from whatever data source names them (a CMS, a DB table, …), as an additionalSitemapSourceRegistrysource alongside the one built fromdiscoverAppRoutes.
Never throws: a missing/unreadable appDir, or an unreadable subtree hit mid-walk, is reported in
errors ({ path, error }[]) instead — the same per-source error isolation as
SitemapSourceRegistry.collect()'s { entries, errors }.
Testing — SSR content guardrail
hazo_seo/testing gives you a CI/build-time check that a route's server-rendered HTML actually
carries content, so a page silently regressing to client-only rendering (see above) fails a build
instead of a Bing report:
import { assertServerText } from 'hazo_seo/testing';
// Throws ServerTextAssertionError listing any route under the word threshold.
await assertServerText(['/my-tool', '/about'], {
baseUrl: 'http://localhost:3000',
minWords: 150, // default
});checkServerText(routes, opts) returns the same per-route results ({ route, words, pass, ... })
without throwing, if you'd rather assert on the array yourself.
ESLint rule — seo/no-client-page-root
hazo_seo/eslint ships a rule that flags a module-level "use client" directive in a Next.js App
Router page.tsx/page.ts, since it forces the whole route to render client-side:
// eslint.config.js (flat config)
import { rules } from 'hazo_seo/eslint';
export default [
{
plugins: { seo: { rules } },
rules: { 'seo/no-client-page-root': 'warn' },
},
];Use the rule's { allow: ['**/embed/**'] } option to exempt routes that are intentionally
client-only.
Tailwind v4 @source (required)
Add hazo_seo's dist to your Tailwind source scan so JIT generates utility classes from its components:
/* your-app/app/globals.css */
@import "tailwindcss";
@source "../node_modules/hazo_seo/dist";Adjust the relative path based on your project structure. Workspace monorepos may need ../../../node_modules/hazo_seo/dist.
Also import the CSS variables if you use hazo_ui components alongside hazo_seo:
// layout.tsx or _app.tsx
import 'hazo_ui/styles.css';Next.js transpilePackages
// next.config.js
const nextConfig = {
transpilePackages: ['hazo_seo', 'hazo_ui', 'hazo_core'],
};License
MIT
