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

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/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 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
});
// 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

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.

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