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

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_logs

Optional 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_api

Sub-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 isolation

buildSitemapXml(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 input

experiment (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: trackRouteChanges defaults 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 enable trackRouteChanges if you've turned enhanced measurement's pageview tracking off. (For the same reason, @next/third-parties' own GoogleAnalytics component 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's page.tsx isn't an independently navigable URL; the matching page is the parent segment's own page.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 additional SitemapSourceRegistry source alongside the one built from discoverAppRoutes.

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