hazo_seo
v0.4.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/schema | 1 | JSON-LD builders (coming v0.3) |
| 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
});
// 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 inputBuilding 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.
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
