Skip to main content

API reference

The package has five entries. The core and /schema have no dependency and run in Node and in a browser. /react and /expo-router need React Native. /node runs in Node only.

@rootnative/seo — core (no dependency)​

import { defineSite, toHeadTags, clipDescription, isoDuration, serializeJsonLd, resolveUrl } from '@rootnative/seo'
import type { PageMeta, SiteConfig, Site, HeadTag, JsonLd, OpenGraphType } from '@rootnative/seo'
  • defineSite(config: SiteConfig): Site — fills the defaults. Throws on an empty name or a relative url. Strips a trailing slash from url.
    • SiteConfig: name, url (absolute origin + base path), locale?, titleTemplate?: (title) => string (default "<title> | <name>", and the bare name when the title equals it), twitterSite? (@handle), descriptionLimit? (default 160).
  • toHeadTags(site, meta: PageMeta): HeadTag[] — tag records, not elements. Any head renderer can write them.
    • PageMeta: title, description?, url? (absolute or relative to the site), image?: string | null, imageSize?: { width, height }, imageAlt?, type?: 'website' | 'article' | 'profile' | 'video.movie' | 'product', locale?, noindex?, twitterCard?: 'summary' | 'summary_large_image', jsonLd?: JsonLd[].
    • HeadTag: { tag: 'title', text } | { tag: 'meta', name, content } | { tag: 'meta', property, content } | { tag: 'link', rel, href } | { tag: 'script', type: 'application/ld+json', text }.
    • Rules: the tab title uses the template, og:title does not. No url → no canonical link and no og/twitter tags. og:image:width/height only with an image and an imageSize. Twitter card summary_large_image with an image, summary without, unless twitterCard forces one. noindex writes <meta name="robots" content="noindex">. Each tag is written once.
  • clipDescription(text, limit = 160) — collapses whitespace, clips at a word boundary, adds one ….
  • isoDuration(minutes) — 150 → 'PT2H30M'. Returns null for 0, a negative, or a non-finite number.
  • serializeJsonLd(value) — JSON.stringify with every < as \\u003c, so </script> in a title cannot end the tag.
  • resolveUrl(site, pathOrUrl) — joins a path under the site; an absolute URL passes through.

@rootnative/seo/schema — JSON-LD builders (no dependency)​

Each returns a JsonLd with @context: 'https://schema.org' and @type set. Every empty field (undefined, null, "", [], {}) is removed at every depth.

  • webSite({ name, url, searchUrlTemplate? }) — adds a SearchAction with query-input: 'required name=search_term_string' when the template is set.
  • breadcrumbList(trail: { name, url }[]) — ListItems with position from 1.
  • organization({ name, url, logo?, sameAs? })
  • article({ headline, url, image?, description?, datePublished?, dateModified?, author? }) — author is a string or { name, url? }, written as a Person. Sets mainEntityOfPage to url.
  • product({ name, url, image?, description?, brand?, sku?, offers?, aggregateRating? }) — brand becomes Brand; offers: { price, priceCurrency, availability?, url? } becomes Offer with availability as https://schema.org/<name>.
  • movie({ name, url, image?, description?, datePublished?, genre?, durationMinutes?, actors?, director?, rating? }) — duration through isoDuration; actors and director as Person.
  • person({ name, url, image?, description?, birthDate?, deathDate?, birthPlace?, jobTitle?, sameAs? }) — birthPlace as Place.
  • event({ name, url, startDate, endDate?, location?, image?, description? }) — location is a string or { name, address? }, as Place.
  • faqPage(questions: { question, answer }[]) — Question + Answer under mainEntity.
  • RatingInput: { ratingValue, ratingCount?, reviewCount?, bestRating? (5), worstRating? (1) }. A rating with no count is left out.

@rootnative/seo/react — peers: react >=19.2.3 <20.0.0, react-native >=0.83.0 <0.87.0​

  • <SeoProvider site={site}> — gives every PageHead below it the site.
  • useSite(site?) — the site for the tree. A site argument wins. Throws with no provider and no argument.
  • <Heading level={1..6} as={Component}> — renders the given text component (default RN Text) with accessibilityRole="header" and aria-level. On the web react-native-web writes <h1>..<h6>. Generic: <Heading<TypographyProps> level={2} as={Typography} variant="titleMedium">. The wrapped component's own as, role, accessibilityRole and aria-level props are omitted.
  • imageLabel(kind, subject?) — imageLabel('Poster', 'Dune') → 'Dune poster'. Set the result as both alt and accessibilityLabel on expo-image (its web alt comes from accessibilityLabel).

@rootnative/seo/expo-router — optional peer: expo-router >=57.0.0​

  • <PageHead {...meta} site?> — PageMeta as props, plus an optional site that wins over the provider. Writes the tags through expo-router/head. On native it renders null and reads no site, so a native tree or test needs no SeoProvider. On the web a missing site throws, and the static export fails.
  • renderHeadTag(tag, index) — one HeadTag as the element expo-router/head expects. A JSON-LD script is written as a string child, not dangerouslySetInnerHTML (react-helmet-async drops the latter).
  • shellTags(options): ReactElement[] — for app/+html.tsx: charset, viewport (default width=device-width, initial-scale=1, shrink-to-fit=no), themeColor, favicon, appleTouchIcon, manifest, preconnect: { href, crossOrigin? }[]. themeColor is a string or { light, dark }; the object form writes one theme-color tag per prefers-color-scheme, which follows the system scheme, not a mode picked in the app. basePath (pass process.env.EXPO_BASE_URL) prefixes every relative href. No router dependency: works in a Vite template through renderToStaticMarkup. Expo Router writes the PageHead tags before the shell, so the charset can land after byte 1024: the host must send Content-Type: text/html; charset=utf-8.

@rootnative/seo/node — Node only, no peer​

  • rootnative-seo <outDir> [--site-url <url>] [--sitemap <url>]... [--no-robots] — the command for the deploy step, after the export. It runs writeSeoFiles, prints each page it leaves out with the reason, and exits with 1 on an error. --sitemap replaces the sitemap list in robots.txt; repeat it for more than one.
  • writeSeoFiles({ outDir, siteUrl?, robots? }) — reads every .html file under outDir and writes sitemap.xml and robots.txt. A page goes in the sitemap with its canonical link, when the link is under the site and the page has no noindex. Left out, with a reason: a special route (a + or _ segment, or a root 404.html), a dynamic route template ([id].html), noindex, no canonical link, a canonical link outside the site, and a second page with the same link. siteUrl defaults to the canonical link of index.html. robots: false writes no robots.txt; an object passes options to writeRobots. Resolves to { siteUrl, sitemap, robots?, urls, skipped: { file, reason }[] }. Each sitemap entry holds loc only: Google ignores changefreq and priority, and an export does not know lastmod. Throws when no page can go in the sitemap, because an empty sitemap fails with no other signal.
  • readExport(outDir) — { file, route, canonical?, noindex }[] for every exported HTML file, sorted by path. Use it to build a URL list of your own.

The three functions below write one file each. Each creates outDir, writes the file and resolves to its path. Each has a pure sibling that returns the text.

  • writeSitemap({ outDir, siteUrl, urls, fileName? }) / sitemapXml({ siteUrl, urls }) — urls: { loc, lastmod?, changefreq?, priority? }[]. A relative loc is joined under siteUrl. Every value is XML-escaped. priority is clamped to 0..1.
  • writeRobots({ outDir, sitemapUrl, disallow?, allow?, userAgent?, fileName? }) / robotsTxt(...) — sitemapUrl is one URL or a list. With no disallow the file allows everything. The file carries a comment that says why an overlay route is not disallowed: a crawler reads noindex only on a page it may fetch.
  • writeManifest({ outDir, name, shortName?, description?, startUrl? ('./'), scope? ('./'), display? ('standalone'), themeColor?, backgroundColor?, lang?, icons?, fileName? }) / manifestJson(...) — relative paths, so it works under a subpath host. The icon type is inferred from the file extension.