@sdxc/seo
Canonical URLs, typed schema.org structured data and head metadata
- Depends on
remix- Used by
- uptime, books
- Source
- packages/seo
Canonical URLs, typed schema.org structured data and head metadata, all resolved through one configured instance.
A page's head has rules that are easy to get subtly wrong: the canonical URL must name one
origin regardless of which host served the request, structured data must spell @context
and @type exactly, and JSON-LD must not let page content close its own <script>.
createSeo() takes the site's identity and returns everything else, so one install serves
any site and the copy stays in the application.
Installation
npm add @sdxc/seo
The head elements are remix components, rendered
by remix/ui, which installs alongside this package. The URL helpers, schema builders and
jsonLdString are plain functions and run anywhere.
Usage
Configure Once
import { createSeo } from "@sdxc/seo";
let seo = createSeo({
baseUrl: "https://example.com",
siteName: "Example",
defaultDescription: "A searchable catalog of public datasets, with an API.",
twitter: { site: "@example" },
});
Canonical And Absolute URLs
seo.canonical("/features/search/"); // "https://example.com/features/search"
seo.canonical("https://preview.workers.dev/pricing"); // "https://example.com/pricing"
seo.canonical("/docs?section=api"); // "https://example.com/docs?section=api"
seo.absolute("/og/cover.png"); // "https://example.com/og/cover.png"
Whatever host served the request, one canonical URL comes out, so seo.canonical(request.url)
is the whole of what a page does about canonicalization.
Structured Data
let organization = seo.schema.organization({
name: "Example Inc",
logo: "/icon-512.png",
sameAs: ["https://github.com/example"],
});
let breadcrumbs = seo.schema.breadcrumbs([
{ name: "Docs", url: "/docs" },
{ name: "API", url: "/docs/api" },
]);
seo.jsonLdString([organization, breadcrumbs]);
// '[{"@context":"https://schema.org","@type":"Organization",…}]'
Head Elements
import { Seo } from "@sdxc/seo";
<head>
<Seo.Meta
title="API reference"
description="Every endpoint, with request and response examples."
canonical={seo.canonical(request.url)}
site={seo.site}
og={{ type: "article", image: seo.absolute("/og/api.png") }}
/>
<Seo.JsonLd schema={[organization, breadcrumbs]} />
</head>;
API
createSeo(config: SeoConfig): SeoService
Creates the instance a site resolves all of its head metadata through. The configured base URL is reduced to its origin once, and every URL the instance returns is built from that origin rather than from the host that served the request.
config.baseUrl: The site's canonical base URL, as a string orURLconfig.siteName: Site name forog:site_nameand for nodes describing the siteconfig.defaultDescription: Description used when a page or node passes noneconfig.twitter: Optionalsite/creatorhandles andcardlayout
seo.canonical(url: string | URL): string
Resolves a request URL or a root-relative path to the page's one canonical URL.
The configured origin replaces whatever host served the request, the trailing slash is
dropped everywhere but the root, the query string is preserved verbatim, and the hash is
dropped. Because the query string is preserved verbatim, a trailing slash sitting before a
? stays — the slash is only dropped when it is the resolved URL's last character.
seo.absolute(path: string | URL): string
Resolves an asset path against the configured origin, leaving an already-absolute URL alone so a CDN host passes through. It performs no trailing-slash normalization, since an asset URL is not a page URL.
seo.absolute("/og/cover.png"); // "https://example.com/og/cover.png"
seo.absolute("https://cdn.example.net/cover.png"); // unchanged
seo.robotsTag(options?: RobotsOptions): string
Builds the robots meta content for one page from index and follow booleans, both
defaulting to true. Both directives are always spelled out, so the emitted value states
the page's full policy.
seo.robotsTag({ index: false, follow: true }); // "noindex, follow"
seo.robotsTag(); // "index, follow"
seo.jsonLdString(schema: SchemaOrg.Node | SchemaOrg.Node[]): string
Serializes one node or several for a <script type="application/ld+json"> body. Every <
becomes its unicode escape, so no string value can emit a </script or <!-- sequence and
break out of the script element; the JSON still parses back to the original text. Use it
wherever the JSX is not remix/ui, and Seo.JsonLd where it is.
seo.baseUrl: string
The configured origin, with no trailing slash — useful for building URLs the package has no
builder for, such as a feed entry or a
@sdxc/sitemap document.
seo.site: SeoSite
The site identity Seo.Meta needs (name, description, and twitter), so a layout
passes configuration through instead of restating it.
seo.schema: SeoSchema
Typed builders bound to this configuration. Page URLs go through the canonical rules, so a
node's url agrees with the canonical link; image and logo paths are made absolute; dates
accept a Date or an already-formatted string; and optional properties are omitted rather
than emitted empty.
seo.schema.organization(input): SchemaOrg.Organization
The publisher behind the site. url defaults to the configured base URL, and sameAs
carries profile URLs proving the same entity elsewhere.
seo.schema.organization({ name: "Example Inc", logo: "/icon-512.png" });
seo.schema.website(input?): SchemaOrg.WebSite
The site as a whole, for the one page whose subject is the site itself. Name, URL, and
description all fall back to the configuration. A searchAction template is prefixed
without percent-encoding, so the braces around the query placeholder survive.
seo.schema.website({ searchAction: { urlTemplate: "/search?q={search_term_string}" } });
seo.schema.webPage(input): SchemaOrg.WebPage
A page with no more specific type. A single image becomes the page's
primaryImageOfPage.
seo.schema.webPage({
name: "Pricing",
description: "Every plan, and what each one includes.",
url: "/pricing",
image: "/og/pricing.png",
});
seo.schema.article(input): SchemaOrg.Article
A dated, authored page. The byline defaults to a Person; pass
author: { name, kind: "Organization" } for an organizational one. A single image is
normalized into a list.
seo.schema.article({
headline: post.title,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: { name: "Sergio", url: "/about" },
image: post.cover,
url: `/blog/${post.slug}`,
});
seo.schema.breadcrumbs(crumbs): SchemaOrg.BreadcrumbList
The trail leading to the current page. Positions are numbered from the given order.
seo.schema.breadcrumbs([
{ name: "Home", url: "/" },
{ name: "Docs", url: "/docs" },
]);
seo.schema.faq(questions): SchemaOrg.FAQPage
A page's question-and-answer section. Pass the same pairs the page renders: Google's structured data policies require the marked-up answers to be visible to the visitor.
seo.schema.faq([{ question: "Is there a free plan?", answer: "Yes, up to 10 projects." }]);
seo.schema.softwareApplication(input): SchemaOrg.SoftwareApplication
A product or capability page whose subject is the software itself. It takes one offers
object, since an application page quotes one price.
seo.schema.softwareApplication({
name: "Example",
applicationCategory: "WebApplication",
operatingSystem: "Any",
offers: { price: "0", priceCurrency: "USD", description: "Usage-based pricing" },
featureList: ["Full-text search", "Bulk export"],
});
seo.schema.book(input): SchemaOrg.Book
A page selling one book. Only the title and its author are required; every other property
is one a sales page either knows or leaves out. The byline behaves as article's, a single
cover image is normalized into a list, and bookFormat takes a
schema.org BookFormatType URL so a typo cannot
compile. Pass an array to offers when the book is sold as more than one package: each
price is its own Offer, and a single offer is normalized into the same list.
seo.schema.book({
name: "Álem",
author: { name: "Sergio", url: "/about" },
description: "A novel about the places a language remembers.",
url: "/",
image: "/og.jpg",
bookFormat: "https://schema.org/EBook",
inLanguage: "es",
numberOfPages: 180,
offers: [
{ price: "29", priceCurrency: "USD", description: "Book", url: "/checkout" },
{ price: "49", priceCurrency: "USD", description: "Book and workshop", url: "/checkout/pro" },
],
});
Seo
remix/ui component emitting a page's whole head contribution: the Seo.Meta tag set, plus
a Seo.JsonLd script when schema is given.
Props:
Everything
Seo.Metaacceptsschema?:SchemaOrg.Node | SchemaOrg.Node[]- Structured data for the page
<Seo title={title} canonical={seo.canonical(request.url)} site={seo.site} schema={article} />
Seo.Meta
remix/ui component emitting the title, description, canonical link, robots directives, and
the Open Graph and Twitter tag sets. Both social namespaces restate the title and
description, because every consumer of these cards reads its own namespace and ignores the
other's. A tag whose input is missing is skipped, so a page states only what it has.
Props:
title?:string- The page title, already localizeddescription?:string- Meta description. Falls back tosite.descriptioncanonical:string- The page's canonical absolute URL, fromseo.canonical()site?:SeoSite- Site identity, fromseo.siteog?:Seo.OpenGraph-type,image,imageAlt, andlocalerobots?:string- Content fromseo.robotsTag()
<Seo.Meta
title={title}
description={description}
canonical={seo.canonical(request.url)}
site={seo.site}
og={{ type: "article", image: seo.absolute("/og/cover.png") }}
/>
Seo.JsonLd
remix/ui component emitting structured data as one application/ld+json script. Several
nodes go into a single script as an array, which is valid and far easier to audit than
several scripts. The JSON is set through innerHTML, since JSX escapes text nodes and would
leave the data unparseable.
Props:
schema:SchemaOrg.Node | SchemaOrg.Node[]- The nodes to serialize
<Seo.JsonLd schema={[organization, article, breadcrumbs]} />
Types
SeoConfig
interface SeoConfig {
baseUrl: string | URL;
siteName: string;
defaultDescription: string;
twitter?: SeoTwitter;
}
SeoSite
interface SeoSite {
name: string;
description: string;
twitter?: SeoTwitter;
}
SeoTwitter
interface SeoTwitter {
site?: string;
creator?: string;
card?: "summary" | "summary_large_image";
}
RobotsOptions
interface RobotsOptions {
index?: boolean;
follow?: boolean;
}
SeoService
The interface createSeo returns: baseUrl, site, schema, and the canonical,
absolute, robotsTag and jsonLdString methods.
SeoSchema
The builder set exposed as seo.schema, with one method per node type.
SchemaOrg
Namespace of the node shapes the builders return (SchemaOrg.Organization,
SchemaOrg.Article, SchemaOrg.BreadcrumbList, …), their nested types
(SchemaOrg.ListItem, SchemaOrg.Offer, …), and the inputs the builders accept
(SchemaOrg.ArticleInput, …). SchemaOrg.Node is the union of every top-level node, and is
what serialization accepts.
Pattern: One Instance Per Site
The configuration is read once at boot, so build the instance once and share it rather than calling the factory per request.
import { createSeo } from "@sdxc/seo";
/** The site's one SEO instance, shared by every page and layout. */
export const SEO = createSeo({
baseUrl: "https://example.com",
siteName: "Example",
defaultDescription: "A searchable catalog of public datasets, with an API.",
twitter: { site: "@example" },
});
Pattern: A Document Layout That Takes Metadata As Input
Let the layout accept the metadata input and pass it straight through, so each page decides its own copy and structured data while the tag set stays identical everywhere.
import { Seo } from "@sdxc/seo";
import type { Handle, RemixNode } from "remix/ui";
interface Props {
children: RemixNode;
locale?: string;
seo?: Seo.Props;
}
function DocumentLayout(handle: Handle<Props>) {
return () => (
<html lang={handle.props.locale}>
<head>
<meta charSet="utf-8" />
{handle.props.seo && <Seo {...handle.props.seo} />}
</head>
<body>{handle.props.children}</body>
</html>
);
}
Pattern: Serializing Outside remix/ui
Where the JSX is not remix/ui, build the same nodes and serialize them by hand.
let body = seo.jsonLdString([organization, article]);
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: body }} />;
Pattern: Keeping A Page Out Of The Index
Signed-in screens and thin filtered views say so per page; site-wide crawl policy belongs in
robots.txt.
<Seo.Meta
canonical={seo.canonical(request.url)}
robots={seo.robotsTag({ index: false, follow: true })}
/>