stoneware

Documentation

SEO and sharing

One seo() call for search engines, every social network, and rich results.

Metadata is a lot of tags to remember and easy to get subtly wrong. seo() takes one object and emits only what you filled in — every field is optional, and an omitted field produces no tag rather than an empty one.

routes/quiz/java.tsxtsx
export function head() {
  return seo({
    title: "Java Quiz",
    description: "Practice Java questions online.",
    canonical: "https://example.com/quiz/java",
  });
}
the whole outputtxt
<title>Java Quiz</title>
<meta name="description" content="Practice Java questions online.">
<link rel="canonical" href="https://example.com/quiz/java">

Three fields in, three tags out. It is a convenience over writing them yourself, never a gate in front of them — the result is an ordinary fragment, so hand-written tags sit beside it in the same head.

There are only three audiences

The list of networks is long; the list of protocols is not. Knowing which is which is most of the work.

  Open Graph        Facebook, Instagram, LinkedIn, WhatsApp,
                    Slack, Discord, Telegram, Signal, Pinterest,
                    iMessage, Teams
                    -> openGraph: { ... }

  twitter:*         X   (renamed the company, not the markup)
                    -> x: { ... }

  Google            title, description, canonical, robots
                    + schema.org for rich results
                    -> jsonLd: { ... }
who reads what
There is no instagram or linkedIn option because there would be nothing to put in one. Both read Open Graph and neither defines tags of its own — Instagram has no link-preview protocol at all.

The fuller shape

everything is optionaltsx
seo({
  title: "Java Quiz",
  description: "Practice Java questions online.",
  canonical: "https://example.com/quiz/java",

  openGraph: {
    image: "/images/java-quiz.png",   // made absolute for you
    imageWidth: 1200,
    imageHeight: 630,
    siteName: "Example",
    type: "article",
    article: { publishedTime: "2026-08-13", authors: [".../ada"] },
  },

  x: { card: "summary_large_image", site: "@example" },

  robots: { index: true, follow: true, maxImagePreview: "large" },

  alternates: [{ hreflang: "fr", href: "https://example.com/fr/quiz" }],

  jsonLd: { "@context": "https://schema.org", "@type": "Quiz", name: "Java Quiz" },
})

Four things it does for you

  • Relative image paths become absolute, against canonical or the current origin. A relative og:image is dropped by most crawlers, and the failure is invisible until someone shares the link.
  • Open Graph tags use property, not name. Writing name="og:title" is the most common mistake in hand-written metadata and it silently does nothing.
  • og:title, og:description, twitter:title and the rest fall back to the top-level values, so the common case is written once rather than three times.
  • The card type defaults to summary_large_image when there is an image and summary when there is not — a large-image card with no image renders as a bare link.

Structured data

jsonLd is the lever for Google rich results — star ratings, breadcrumbs, FAQ accordions, recipe cards. None of the meta tags above can produce them; only schema.org can.

It is serialized into a application/ld+json block, which browsers parse as data and never execute — the same mechanism the island payload uses, and the reason it needs no CSP exception. The serializer escapes <, > and the line separators, so a value cannot close the element and inject markup.

Something wrong in the framework itself rather than the page? Open an issue on GitHub.