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 emptynameor a relativeurl. Strips a trailing slash fromurl.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:titledoes not. Nourl→ no canonical link and no og/twitter tags.og:image:width/heightonly with an image and animageSize. Twitter cardsummary_large_imagewith an image,summarywithout, unlesstwitterCardforces one.noindexwrites<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'. Returnsnullfor0, a negative, or a non-finite number.serializeJsonLd(value)—JSON.stringifywith 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 aSearchActionwithquery-input: 'required name=search_term_string'when the template is set.breadcrumbList(trail: { name, url }[])—ListItems withpositionfrom 1.organization({ name, url, logo?, sameAs? })article({ headline, url, image?, description?, datePublished?, dateModified?, author? })—authoris a string or{ name, url? }, written as aPerson. SetsmainEntityOfPagetourl.product({ name, url, image?, description?, brand?, sku?, offers?, aggregateRating? })—brandbecomesBrand;offers: { price, priceCurrency, availability?, url? }becomesOfferwithavailabilityashttps://schema.org/<name>.movie({ name, url, image?, description?, datePublished?, genre?, durationMinutes?, actors?, director?, rating? })—durationthroughisoDuration;actorsanddirectorasPerson.person({ name, url, image?, description?, birthDate?, deathDate?, birthPlace?, jobTitle?, sameAs? })—birthPlaceasPlace.event({ name, url, startDate, endDate?, location?, image?, description? })—locationis a string or{ name, address? }, asPlace.faqPage(questions: { question, answer }[])—Question+AnswerundermainEntity.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 everyPageHeadbelow it the site.useSite(site?)— the site for the tree. Asiteargument wins. Throws with no provider and no argument.<Heading level={1..6} as={Component}>— renders the given text component (default RNText) withaccessibilityRole="header"andaria-level. On the web react-native-web writes<h1>..<h6>. Generic:<Heading<TypographyProps> level={2} as={Typography} variant="titleMedium">. The wrapped component's ownas,role,accessibilityRoleandaria-levelprops are omitted.imageLabel(kind, subject?)—imageLabel('Poster', 'Dune')→'Dune poster'. Set the result as bothaltandaccessibilityLabelonexpo-image(its webaltcomes fromaccessibilityLabel).
@rootnative/seo/expo-router — optional peer: expo-router >=57.0.0
<PageHead {...meta} site?>—PageMetaas props, plus an optionalsitethat wins over the provider. Writes the tags throughexpo-router/head. On native it rendersnulland reads no site, so a native tree or test needs noSeoProvider. On the web a missing site throws, and the static export fails.renderHeadTag(tag, index)— oneHeadTagas the elementexpo-router/headexpects. A JSON-LD script is written as a string child, notdangerouslySetInnerHTML(react-helmet-async drops the latter).shellTags(options): ReactElement[]— forapp/+html.tsx: charset, viewport (defaultwidth=device-width, initial-scale=1, shrink-to-fit=no),themeColor,favicon,appleTouchIcon,manifest,preconnect: { href, crossOrigin? }[].themeColoris a string or{ light, dark }; the object form writes onetheme-colortag perprefers-color-scheme, which follows the system scheme, not a mode picked in the app.basePath(passprocess.env.EXPO_BASE_URL) prefixes every relativehref. No router dependency: works in a Vite template throughrenderToStaticMarkup. Expo Router writes thePageHeadtags before the shell, so the charset can land after byte 1024: the host must sendContent-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 runswriteSeoFiles, prints each page it leaves out with the reason, and exits with 1 on an error.--sitemapreplaces the sitemap list inrobots.txt; repeat it for more than one.writeSeoFiles({ outDir, siteUrl?, robots? })— reads every.htmlfile underoutDirand writessitemap.xmlandrobots.txt. A page goes in the sitemap with its canonical link, when the link is under the site and the page has nonoindex. Left out, with areason: a special route (a+or_segment, or a root404.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.siteUrldefaults to the canonical link ofindex.html.robots: falsewrites norobots.txt; an object passes options towriteRobots. Resolves to{ siteUrl, sitemap, robots?, urls, skipped: { file, reason }[] }. Each sitemap entry holdsloconly: Google ignoreschangefreqandpriority, and an export does not knowlastmod. 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 relativelocis joined undersiteUrl. Every value is XML-escaped.priorityis clamped to 0..1.writeRobots({ outDir, sitemapUrl, disallow?, allow?, userAgent?, fileName? })/robotsTxt(...)—sitemapUrlis one URL or a list. With nodisallowthe file allows everything. The file carries a comment that says why an overlay route is not disallowed: a crawler readsnoindexonly 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 icontypeis inferred from the file extension.