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

@octanejs/seo

v0.0.32

Published

SEO metadata for Octane, declarative <Title>/<Meta>/<Link>/<JsonLd> with last-wins merging, server-rendered into <head> and adopted on hydration.

Readme

@octanejs/seo

Declarative document metadata for Octane: server-rendered into <head>, adopted on hydration, and merged so the most specific declaration wins.

import { Head, Link, Meta, Script, Title } from '@octanejs/seo';

function App() @{
	<Head>
		<Head>
			<Title text="Acme" />
			<Meta name="description" content="Widgets for everyone" />
		</Head>
		<Router />
	</Head>
}

function ProductPage(props: { product: Product }) @{
	<>
		<Head>
			<Title text={props.product.name} />
			<Meta name="description" content={props.product.blurb} />
			<Link rel="canonical" href={'/p/' + props.product.slug} />
			<Script type="application/ld+json" json={{ '@type': 'Product', name: props.product.name }} />
		</Head>
		<main>…</main>
	</>
}

The product page's title and description replace the app-level ones. Everything else the app declared stays.

Why a merge exists

The platform resolves duplicates by taking the first in tree order: document.title is defined as the first <title> element in the document, and a crawler reads the first meta[name="description"]. Authoring order runs the other way, with app defaults written before page specifics, so simply emitting both would let the generic value win every time. Registrations are therefore keyed by identity and the last one wins.

Identity is what the tag names, not the tag type. meta[name], meta[property], and meta[http-equiv] are separate channels. JSON-LD is keyed by @type (plus @id), so an Article replaces an Article while a BreadcrumbList sits alongside it.

<link> needs three rules, because href is sometimes the value being set and sometimes the thing being identified:

| rel | identity | effect | | --- | --- | --- | | canonical, manifest, author, license, prev, next | rel alone | one per document | | alternate (hreflang/type/media/title), icon and apple-touch-icon (sizes/type), mask-icon, search | the named slot, not href | a page moving the German alternate or the 32×32 icon replaces it | | everything else, including preload, prefetch, preconnect, modulepreload, stylesheet, and any rel not listed above | the target URL | two font preloads or two stylesheets coexist |

Unknown rels fall in the last group deliberately: emitting two tags is a smaller mistake than silently dropping one.

<Head>, and where to put it

Wrap the app in one:

<Head>
	<App />
</Head>

Then use <Head> again wherever metadata belongs. Position carries no meaning. Two blocks merge whether one contains the other or they sit in unrelated components, and precedence never depends on nesting depth: the last registration of a given identity wins, so a page overrides a layout simply by rendering later. Tags written bare under the outer <Head>, with no block around them, behave identically.

The outermost <Head> is what makes that true. The merge has to see every registration before it emits anything, and a string renderer emits in document order, so blocks that owned their own metadata would each emit a set and the platform's first-wins rule would hand the page to whichever rendered first. This would then quietly break:

function Page() @{
	<>
		<Head><Title text="Listing" /></Head>
		<Detail />                    {/* its own <Head> is a SIBLING */}
	</>
}

With an outer <Head> around the app, <Detail> wins as written. A tag with no <Head> above it throws, and two <Head> elements where neither contains the other are reported in development.

Components

| Component | Purpose | | --- | --- | | <Title text="…" /> | Document title | | <Meta name/property/http-equiv … /> | Any meta tag | | <Link rel="…" href="…" /> | canonical, alternate, icon, manifest | | <Script type json / text /> | JSON-LD and other head scripts | | <Seo … /> | The whole metadata object at once |

<Title> takes its text as a prop, not JSX children. Element children compile to a children block (a function), and coercing one to a string would put source code in the document title, so that case throws instead.

<Seo> is the object form and expands to the tags above:

<Seo
	title="Post title"
	description="Post summary"
	canonical="/blog/post"
	site="https://example.com"
	titleTemplate="%s · Example"
	openGraph={{ type: 'article', images: [{ url: '/og.png', alt: 'Post', width: 1200, height: 630 }] }}
	twitter={{ card: 'summary_large_image', site: '@example' }}
	languages={{ de: '/de/blog/post', 'x-default': '/blog/post' }}
	robots={{ index: true, follow: true, maxImagePreview: 'large' }}
	jsonLd={{ '@type': 'Article', headline: 'Post title' }}
/>

App-level settings

Three things are declared once and apply everywhere, because the component that knows them is rarely the one that renders a page:

  • site absolute-ises the URLs a consumer reads without a base: canonical, link rel="alternate" hreflang addresses, og:url, og:image, and twitter:image. It deliberately does not touch subresources the browser fetches, so preload, prefetch, modulepreload, stylesheet, icon, and manifest keep resolving against the document actually serving the response. Rewriting those would make a preview deploy carrying the production site pull fonts, CSS, and modules from production.
  • titleTemplate wraps each page's title, so %s · Acme applies to a page that only sets title: 'Pricing'.
  • The Open Graph and Twitter shell. Declare openGraph/twitter once and og:title, og:description, og:url, twitter:title, and twitter:description are mirrored from whatever page renders, unless that page names them itself. Only families you actually declared are filled, so an app that never asked for Open Graph never emits it.
// once, near the root
<Seo
	site="https://example.com"
	titleTemplate="%s · Example"
	openGraph={{ type: 'website', siteName: 'Example' }}
	twitter={{ card: 'summary_large_image' }}
/>

// and in a page, anywhere below
<Seo title="Pricing" description="Plans and limits." canonical="/pricing" />

All three are applied after the merge, once the whole tree has registered. The social mirror uses the raw title rather than the templated one, since og:site_name already carries what a template adds.

Server rendering

Metadata registered during render reaches <head> in the served HTML, which is the point: an effect-based approach never runs on the server, so crawlers would see nothing. Under @octanejs/vite-plugin this works with no configuration.

For a custom server, render with headChannel: 'separate' and splice the returned metadata into your template's <head>:

const { html, css, head } = await prerender(App, props, { headChannel: 'separate' });

Streaming uses onHeadReady(head), which fires before the shell is written. See docs/ssr.md.

Two caveats worth knowing:

  • Remove any static <title> from index.html. The hoisted metadata is spliced at <!--ssr-head-->, after it, so a template title would win.
  • Metadata that depends on suspended data does not reach a streamed shell. The shell flushes before the data settles. Derive metadata from data you already have, or use the buffered renderer for those routes.

Hydration and navigation

The client adopts the server's elements instead of appending its own, updates them in place rather than replacing them (a swapped <link> would re-fetch its resource), and removes what it owns when a page unmounts, so navigating between routes never accumulates stale canonicals or og:image tags.