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

@nswds/metadata

v1.1.4

Published

Shared Next.js App Router metadata, viewport and web app manifest for the NSW Design System fleet.

Readme

@nswds/metadata

Shared Next.js App Router metadata, viewport and web app manifest for the digitalnsw fleet.

Every site was hand-rolling its own metadata block and they had drifted: most carried no metadataBase, none exported a viewport, and several were quietly telling search engines that every page on the site was the homepage. The defaults that should be identical across every NSW Government service now live in one versioned place; the fields that genuinely differ stay arguments.

Everything here is checked against Next 16.3.0's dist/, not its prose docs, which are wrong in several places that matter — noted inline where relevant.

A consuming repo holds exactly one module that calls defineSite — by convention lib/site.ts. The root layout takes metadata and viewport from it; app/manifest.ts and individual pages take what they need from the same object. See Use for the full shape.

Install

npm install @nswds/metadata

next is a required peer (>=15). The package imports nothing from it at runtime — it is object literals all the way down — but its whole type surface is Next's, so a Next-less install fails loudly rather than at first tsc.

Use

One entry point. Define the site once, in its own module:

// lib/site.ts
import { defineSite } from '@nswds/metadata'

export const site = defineSite({
  title: 'Public Sans (NSW) Download',
  description: 'Download Public Sans, the official NSW Government typeface.',
  url: 'https://public-sans.digital.nsw.gov.au/',
})

Then use it from the three places Next asks for metadata:

// app/layout.tsx
export const { metadata, viewport } = site
// app/manifest.ts
export default site.manifest
// app/guidance/page.tsx
export const metadata = site.page({ title: 'Guidance' })

Three options are required — title, description, url. Everything else has a fleet default: metadataBase, the title template, OpenGraph with en_AU and the NSW image, a summary_large_image Twitter card, robots with max-image-preview:large, a self-resolving canonical, and appleWebApp.

Do not annotate the exports as : Metadata. Next's type plugin does not check export const metadata, so the annotation adds nothing and reintroduces the import type line this package exists to delete.

Why there is no createMetadata

Because a page cannot safely write its own metadata object. Next replaces openGraph and twitter wholesale for any segment that sets them — so this, which looks obviously correct, silently deletes the layout's entire OpenGraph block:

// DON'T
export const metadata = { openGraph: { type: 'article' } }

site.page() and site.article() cannot make that mistake. page() emits no openGraph key at all, because og:title and og:description are inherited from the page's own title and description for free. article() restates the whole block before adding the article:* fields, which is the only safe way to set them.

export const metadata = site.article({
  title: 'New guidance for pharmacists',
  description: 'What changed and when it takes effect.',
  published: '2026-08-01',
  modified: '2026-08-09', // often the most important fact on a government page
  section: 'Health',
})

Titles below the root

site.page() returns { default, template } rather than a bare string, and that shape is load-bearing. resolveTitle returns template: null for a string, and Next stashes that null — so a plain-string title in an intermediate layout wipes the template for everything below it, and a route two levels deep renders Cards instead of Cards | Site.

Two escape hatches:

// Opts out of the template for this segment; routes below it are unaffected.
site.page({ title: { absolute: 'Page not found' } })

// A sub-brand section: its own template, for this segment and everything below.
site.page({ title: 'Signature builder', titleTemplate: '%s | NSW Signatures' })

Escape hatch

site.metadata is a plain object, so anything the options do not cover is a spread:

export const metadata = {
  ...site.metadata,
  alternates: { canonical: './', languages: { 'en-AU': '/' } },
}

Note the asymmetry: openGraph passed to defineSite is shallow-merged over the computed defaults, so openGraph: { type: 'article' } keeps locale, siteName and images. That is true of our argument and untrue of Next's segment merging — see above.

Icons: leave them to the file conventions

Three files, in app/. Nothing in public/. Nothing else, anywhere.

| File | Spec | | -------------------- | --------------------------------------------------------------------------------------- | | app/favicon.ico | single 32×32 frame, 1–5 KB | | app/icon.svg | square viewBox, dark mode via inline @media (prefers-color-scheme: dark), no <text> | | app/apple-icon.png | 180×180, fully opaque, no pre-rounded corners |

That set emits exactly this, content-hashed, with sizes and type derived from the files themselves:

<link rel="icon" href="/favicon.ico?<hash>" sizes="32x32" type="image/x-icon" />
<link rel="icon" href="/icon.svg?<hash>" sizes="any" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-icon.png?<hash>" sizes="180x180" type="image/png" />

defineSite emits no icons key, and you should almost never set one. Setting metadata.icons does not add to the file conventions — it suppresses them:

if (leafSegmentStaticIcons.icon.length > 0 || …apple.length > 0) {
  if (!resolvedMetadata.icons) { …unshift the static icons… }
}
// next/dist/lib/metadata/resolve-metadata.js:812

The failure is partial, which makes it harder to notice: the favicon is unshifted unconditionally at :660, so app/favicon.ico survives while icon.svg and apple-icon.png emit nothing. Your tab icon still looks right; only the Slack card is wrong. icons: null is safe (the guard stays falsy); icons: {} suppresses everything and emits no tags at all.

fixtures/next-app asserts on every build that all three tags survive.

Why keep favicon.ico when every browser reads the SVG? It is the only icon that answers a bare /favicon.ico request — feed readers, link-preview scrapers, security scanners and uptime dashboards fetch that path without ever parsing your HTML.

Dark mode belongs inside the SVG, never in an icons[].media descriptor: media on <link rel=icon> was never reliably implemented, browsers resolve the favicon once at parse time, and reaching for it costs you the other two files.

Dead, do not add: mask-icon (Safari stopped using it in Safari 12), msapplication-* and browserconfig.xml (Windows 8 tiles via retired browsers), rel="shortcut icon", apple-touch-icon-precomposed (Next warns), numbered icon0…9.png (the suffix is a single digit — icon10.png is a silent no-op), and app/apple-icon.svg (extension not allowed, silently ignored). shortcut icon and mask-icon are worse than merely dead: both fall outside Next's streaming-metadata reinsert selector, so they strand in <body>.

Manifest icons are a separate problem

site.manifest carries no icons by default. A manifest icon has to be a stably-named file, and no file-convention icon can ever be one — every static metadata image gets ?<16-hex-digest> appended, unconditionally. The conventions also cannot express purpose: 'maskable', which Android's launcher needs.

So a site that wants an install-prompt icon ships real PNGs in public/ and opts in:

import { defineSite, manifestIcons } from '@nswds/metadata'

export const site = defineSite({
  title: '…',
  description: '…',
  url: '…',
  manifest: { icons: manifestIcons() },
})

manifestIcons() expects icon-192.png, icon-512.png, icon-maskable-192.png, icon-maskable-512.png and icon.svg in public/. Without them, leave it unset: an empty manifest and one full of 404s look identical to the browser, but the empty one is honest.

Manifest ownership runs the opposite way to icons. mergeStaticMetadata does a bare if (manifest) { target.manifest = manifest }, so app/manifest.ts overwrites any metadata.manifest value. That is why this package sets none — a default would be dead code where the route exists and a link to a 404 where it does not.

What this deliberately does not emit

Each of these is a decision, recorded so nobody re-adds it:

| Field | Why not | | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | keywords | Ignored by Google since 2009, a spam signal to Bing. One identical keyword fleet-wide reads as low-quality templating. | | applicationName | Legacy Windows pinned tiles. Superseded by the manifest name. | | robots.notranslate | Tells Google not to offer translation. Indefensible for a NSW audience. | | viewport.userScalable / maximumScale | user-scalable=no and a max scale under 2 fail WCAG 2.2 SC 1.4.4 (F69). Passing either throws. | | manifest.orientation | Locking an installed app to portrait fails SC 1.3.4, and only manifests in the installed app — so it survives every browser-based audit. | | classification | Reads like the NSW information-classification scheme. A protective marking in the HTML of a public page is a leak. | | generator | Publishing the exact stack of a government site. Next does not set it; the omission is deliberate. | | referrer | Real directive, wrong layer — Referrer-Policy as an HTTP header covers images, JSON and downloads too. | | creator, publisher, category, abstract, archives, assets, bookmarks | Unregistered or unconsumed link types and meta names. | | facebook, itunes, appLinks | Bind the page to a third-party app for no rendering benefit. | | pinterest | Non-functional in 16.3.0 — Next emits property= where the spec wants name=. | | themeColor, colorScheme, viewport on Metadata | Dead. No branch of the emitter reads them; they emit nothing and warn once per render. Use the viewport export. |

Two live traps worth knowing even though the package handles them: formatDetection.telephone: false kills iOS tap-to-call on the very pages that exist to generate calls (exposed, never defaulted), and robots: { noindex: true } — the natural spelling — is typed never, emits no robots tag at all, and leaves the page indexed. Use noIndex: true.

A caveat on theme colour

viewport.themeColor and the manifest's theme_color are both #002664, and the test suite checks them against each other. A manifest theme colour cannot be media-dependent, so a site that overrides the viewport with a light/dark array will always disagree with its own installed chrome. Deliberate trade, not an oversight.

Why there is no build step

The published files are index.mjs and a hand-written index.d.mts, shipped as-is — the same shape as @nswds/eslint-config and @nswds/prettier-config.

Those two ship no types at all, which works because neither is ever type-checked. This package is imported from app/layout.tsx in repos that are all strict: true, where an untyped module is a hard TS7016 error. So types are mandatory here, and hand-writing them is what keeps the no-build-step property. A generated .d.ts committed to the repo is a build step in disguise, and drifts silently.

Tests

npm test           # node --test — the returned objects
npm run typecheck  # tsc --noEmit — the shipped declarations
npm run test:build # a real `next build` over fixtures/next-app

test:build is the only check that can reach Next's metadata route loader and the resolver's merge behaviour. It asserts on the rendered HTML of two routes, because the canonical fix is invisible from a single route: at / a correct './' and a broken '/' produce the same string.

Debugging note: a plain curl reproduces the browser streaming path, where metadata renders into <body> and is relocated by an injected script. To see what Slack or Facebook actually receive, send a bot UA:

curl -sA 'facebookexternalhit/1.1' https://site.nsw.gov.au/ | grep -E 'og:|twitter:|rel="icon"'

An absent User-Agent takes the streaming path, not the crawler path.

Releases

semantic-release, on merge to main. Publishing is npm OIDC trusted publishing — there is no NPM_TOKEN secret in this repo.

Shared tooling

This repo is in group 2c of the nswds-devops file sync: the shell scripts in scripts/, the commit-type configs, .npmrc, .nvmrc, renovate.json and most of .github/workflows/ are owned there and overwritten on sync. Do not edit them here.

ci.yml, release.yml, release.config.mjs and scripts/verify-release-published.mjs are deliberately repo-owned — group 2c exists precisely so the sync does not overwrite them and disable trusted publishing.