stoneware

Documentation

SEO and sharing

What a Stoneware site does for search before you configure anything, and the one call that writes the rest of the head.

Two halves. The first is what happens whether or not you do anything, and it follows from rendering the whole document on the server rather than from any feature aimed at search engines. The second is seo(), which writes the metadata you decide on.

Nothing here is a claim about rankings. Every item below is either enforced by the build or visible in the served HTML, which is the only kind of promise worth writing down — how a search engine weights anything this quarter is not ours to say.

The document is complete in the first response

A page with no islands ships zero bytes of JavaScript. Not a small runtime, not a hydration shim — no script tag at all. A crawler that never executes JavaScript still sees every word, because nothing was ever assembled on the client.

  pages shipping no JavaScript      20 of 21
  JavaScript on an article page      0 B
  requests to render one article     2   (document + stylesheet)
measured on a 21-route content site

What you get with curl is what gets indexed, which is worth testing rather than trusting. Pages also carry an ETag, so a crawler re-fetching an unchanged page gets an empty 304 instead of the document again.

Status codes are honest

A soft 404 — a page that says "not found" while returning 200 — is the most common indexing bug on a content site, because a dynamic route matches any slug and then discovers there is no such post. notFound() is the fix, and it produces a real 404 with your error page rendered into it.

routes/blog/[slug].tsxtsx
import { notFound, type PageProps } from "stoneware";

export default function Post({ params }: PageProps) {
  const post = getPost(params.slug);
  if (!post) notFound();      // 404, not a 200 that says "not found"

  return <article>{post.title}</article>;
}
  /no-such-page      404      the _404 page, no-store
  /_404              404      a convention, never servable as a page
  notFound()         404      your _404 page, correct status
  a thrown error     500      the _500 page, no-store
what the framework answers without being asked

Errors carry Cache-Control: no-store, so a 404 held by a CDN cannot outlive the deploy that adds the missing page. And a leading underscore keeps routes/_404.tsx from being reachable at /_404 with a 200 — without that, your error page would be indexable content.

Canonical URLs survive the proxy

This one bites almost every deployment and is invisible locally. Every platform that terminates TLS — Render, Railway, Fly, Vercel, nginx — forwards a plain HTTP request to your app. So new URL(request.url) says http:// for a site served over https://, and every absolute URL built from it points at the wrong origin: canonical tags, og:image, sitemap entries, OAuth redirects.

stoneware.config.tstsx
export default defineConfig({
  trustProxy: "proto",   // or STONEWARE_TRUST_PROXY in the environment
});

With it set, the url every page and action receives is the public one. "proto" honours the forwarded scheme only, which is safe on any host and enough to fix this; true also honours the forwarded host, which needs a proxy you control.

The failure mode is silent and it compounds: a canonical tag pointing at http:// tells a crawler your https:// page is a duplicate of a page that redirects. Nothing errors, and the site looks fine to you.

seo() writes the head

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. Call it from head rather than from the body; see head and images for why that export runs when it does.

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.

Three mistakes the tooling catches

Metadata in the wrong place, links that point nowhere, and pages the export silently skipped — all three are found before they ship rather than by a crawler weeks later.

1. seo() called from the wrong place — development onlytxt
[stoneware] seo() was called while rendering /about, not from its head export.
  Those tags land in <body>, where nothing reads them. Move the call into:
    export function head(props) { return seo({ ... }); }

Tags in <body> are not read by anything. The warning names the route, and it stays quiet for a page that owns its whole document, where the call is legitimate.

2 and 3. the export checks its own outputsh
stoneware export --strict

The export follows every same-origin href and src in the pages it wrote and reports the ones that resolve to nothing — src as well as href, because a missing stylesheet or island chunk is the same failure. It also reports any route it skipped for having no staticPaths(). With --strict, either one fails the build instead of printing a note you scroll past.

sitemap()

seo() covers one page. sitemap() covers which pages exist. It is a route that returns XML, not configuration, so it can read the same data the pages render and stay correct without a build step. create-stoneware scaffolds this file and robots.txt.ts alongside it.

routes/sitemap.xml.tsts
import { sitemap } from "stoneware";
import { SITE_URL } from "../lib/site.ts";
import { POSTS } from "../lib/posts.ts";

export function GET(): Response {
  return sitemap(
    [
      { url: "/", changeFrequency: "weekly", priority: 1 },
      ...POSTS.map((post) => ({
        url: `/blog/${post.slug}`,
        lastModified: post.published,
      })),
    ],
    { origin: SITE_URL },
  );
}

Derive the entries from the same data the pages render, and the sitemap cannot drift from the site. Every field except url is optional: lastModified takes a Date or a string, changeFrequency takes the seven values the schema allows, and priority is 0 to 1 relative to your own other pages rather than to anyone else's.

  • URLs are XML-escaped — including the apostrophe, which is legal in a URL and illegal unescaped in XML. Bun.escapeHTML is the wrong tool here and produces a document some parsers reject.
  • A path is resolved against origin. Passing one with no origin is refused rather than emitted, because a relative loc parses fine and no crawler can use it.
  • A date-only string is passed through unchanged. Round-tripping it through Date would shift it by the local UTC offset and publish the wrong day for half the world.
  • Duplicates are written once, an out-of-range priority is refused, and more than 50,000 entries is refused with a pointer to sitemap indexes.
It does not enumerate your routes for you. The framework knows every pattern, so it could — but it cannot know which ones belong in a sitemap. A checkout confirmation, a page behind a login, an archive you would rather have crawled through links: all routes, none of them entries. That is an editorial decision, and a guess at it would produce a file that is confidently wrong.

sitemapXML() returns the same document as a string, for writing to a file, snapshotting in a test, or nesting inside a sitemap index.

What none of this does

  • It does not write your metadata. seo() makes the tags easy; deciding what they say is yours.
  • It does not audit content. Headings, alt text, internal linking and whether the page is worth reading are not things a framework can check.
  • It does not make a slow origin fast. Time to first byte is your data layer plus the network; the framework's own share of a request is about 0.07ms.
  • It does not promise rankings, and any framework that does is selling something.

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