stoneware

Documentation

API reference

Everything the package exports, what it is for, and which of them you are unlikely to need.

One entry point for server code and two subpaths for code that runs in the browser. Nothing imported from stoneware reaches the browser unless the file importing it is under islands/. Every example below is complete enough to paste.

  stoneware           everything on this page, server side
  stoneware/signals   signal, computed, effect — inside an island
  stoneware/client    the hydration runtime — rarely imported by hand
the three import paths

Form

A <form> with the CSRF token already in it. Defaults to POST. PUT, PATCH and DELETE are tunnelled through a hidden _method field, because HTML forms cannot send them.

routes/contact.tsxtsx
import { Form } from "stoneware";

export default function Contact() {
  return (
    <Form action="/api/contact" class="stack">
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </Form>
  );
}

Any attribute you add is passed through to the element — class, id, enctype, data-*. A GET form gets no token, because it mutates nothing and the token would only end up in the query string.

Boundary

Catches an error thrown while rendering its children and puts the fallback there instead. The rest of the page still renders and the response is still 200, so one broken widget does not take down an article.

routes/dashboard.tsxtsx
import { Boundary } from "stoneware";

<Boundary fallback={<p>Prices are unavailable right now.</p>}>
  <PriceTable />
</Boundary>;

// As a function, to say something about what failed:
<Boundary fallback={({ error }) => <p>Chart failed: {String(error)}</p>}>
  <Chart />
</Boundary>;
error is only defined in development. In production it is undefined, deliberately — an exception message routinely carries a file path, a query or a connection string, and a fallback is rendered into a page a visitor reads. The error still reaches your observer as event.caught.

Image

An <img> that cannot be written without the things that prevent layout shift. width, height and alt are required arguments, not optional props.

routes/index.tsxtsx
import { Image } from "stoneware";

<Image src="/hero.jpg" width={1200} height={630} alt="" priority />

<Image
  src="/team.jpg"
  width={800}
  height={600}
  alt="The team at the 2026 offsite"
  srcset="/team-800.jpg 800w, /team-1600.jpg 1600w"
  sizes="(max-width: 700px) 100vw, 800px"
/>;

priority marks the one image worth loading before anything else — usually the LCP element. It loads eagerly at high priority and gets a <link rel="preload"> in the head. Marking several images priority means marking none of them. Everything else lazy-loads.

seo

Returns the meta tags for a page. Call it from a route's head export so the tags land in <head>, where they count.

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

export function head({ params }: PageProps) {
  const post = getPost(params.slug);
  return seo({
    title: `${post.title} — My Site`,
    description: post.excerpt,
    canonical: `https://example.com/blog/${post.slug}`,
    openGraph: { type: "article", image: post.cover },
    x: { card: "summary_large_image" },
    jsonLd: {
      "@context": "https://schema.org",
      "@type": "Article",
      headline: post.title,
      datePublished: post.published,
    },
  });
}

jsonLd is the lever for Google rich results — stars, breadcrumbs, FAQ accordions — and none of the other tags can produce them. It is serialised into an application/ld+json block, which browsers never execute and CSP does not govern. robots, alternates, themeColor and facebookAppId are also accepted; see the SEO page for what each one does.

raw, escapeHTML, safeJSONStringify

Everything interpolated into a template is escaped already, including route params — so a slug containing a script tag is inert without anyone thinking about it. These three are for the cases where you need to step outside that.

the three, in order of how often you want themtsx
import { raw, escapeHTML, safeJSONStringify } from "stoneware";

// Almost never. raw() is the only way to emit unescaped markup, and it is
// meant to feel inconvenient. Only for HTML you produced, never for input.
<div>{raw(markdownToHTML(post.body))}</div>;

// When you are building a string by hand rather than a tree.
const title = escapeHTML(userSuppliedTitle);

// Embedding data in a page. Not JSON.stringify — see below.
<script type="application/ld+json">{raw(safeJSONStringify(schema))}</script>;

safeJSONStringify escapes <, > and & so a payload can never terminate the containing element, and U+2028/U+2029 because they are legal raw in JSON and are line terminators in JavaScript. JSON.stringify does neither, which is the path from a string in a database to script execution. It is what island props already travel through, and it is exported so your own embedded data gets the same treatment.

what the difference looks likets
JSON.stringify({ a: "</script><b>" })
// {"a":"</script><b>"}        ← ends the script tag

safeJSONStringify({ a: "</script><b>" })
// {"a":"\u003c/script\u003e\u003cb\u003e"}

notFound and isNotFound

notFound() ends the render and produces a 404 with your _404 page in it. Callable from anywhere a render reaches — a route, a template, a helper three files deep.

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

export default function Post({ params }: PageProps) {
  const post = getPost(params.slug);
  if (!post) notFound();          // returns never — nothing after this runs

  return <article><h1>{post.title}</h1></article>;
}

It travels as an exception, which is why isNotFound() exists. A try/catch around code that might call it will otherwise swallow a routing decision and render a fallback with a 200 — a page that says "not found" while telling every crawler it is fine.

any catch that wraps a renderts
import { isNotFound } from "stoneware";

try {
  return await renderPost(params);
} catch (error) {
  if (isNotFound(error)) throw error;   // let the 404 through
  logger.error(error);
  return <Fallback />;
}
Boundary already does this for you — it re-throws notFound() rather than absorbing it. This only matters in a try/catch you wrote yourself.

requestURL

The public URL of a request, with trusted proxy headers applied. You rarely call it: props.url on every page and action is already this. Reach for it when you are holding a Request outside the pipeline.

routes/api/callback.tsts
import { requestURL, type ActionContext } from "stoneware";

export async function POST({ request }: ActionContext) {
  const url = requestURL(request, "proto");
  const redirect = new URL("/auth/done", url.origin);
  // https://example.com/auth/done — not http://, and not localhost:3000
  return Response.redirect(redirect, 303);
}
new URL(request.url) is the internal URL. Every platform that terminates TLS forwards plain HTTP to the app, so that URL says http:// for a site served over https://, and anything absolute built from it — canonical links, og:image, OAuth redirects, sitemaps — points at the insecure origin.

The second argument is the same value as the trustProxy config option: false ignores forwarded headers entirely, "proto" honours the scheme only, true also honours the forwarded host. Pass your config's value rather than hardcoding one, or the function disagrees with the rest of the app.

csrfToken and csrfFieldName

For an island doing its own fetch(), which has no form for Form to inject a field into. Both read the render in progress, so they work in a route or a template and nowhere else.

routes/index.tsxtsx
import { csrfToken, csrfFieldName } from "stoneware";

export default function Home() {
  return (
    <>
      <Subscribe token={csrfToken()} />

      {/* Or a hand-built form, if you are not using <Form> */}
      <form action="/api/x" method="POST">
        <input type="hidden" name={csrfFieldName()} value={csrfToken()} />
      </form>
    </>
  );
}

Send the token in the x-csrf-token header from the island. Minting one marks the whole response private, no-store with no ETag, because a fresh token per render means there is nothing stable to cache. csrfFieldName() alone does not — it only reads config.

sitemap and sitemapXML

sitemap() returns a finished Response; sitemapXML() returns the string, for when you want to write it to a file or wrap it yourself. Neither guesses which of your routes belong in a sitemap — that is an editorial decision, so you pass the list.

routes/sitemap.xml.tsts
import { sitemap, type ActionContext } from "stoneware";
import { POSTS } from "../lib/posts.ts";

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

Relative URLs are resolved against origin. lastModified takes a Date or an ISO string. The XML is escaped correctly, including the apostrophe case most hand-rolled sitemaps get wrong, and it refuses more than the 50,000-entry limit rather than emitting a file no crawler will read.

createApp and serve

serve() creates the app and binds a port. createApp() creates the same app and hands it back with a fetch method — same behaviour, no socket, which is what makes tests fast. See testing.

the two of themts
import { serve, createApp } from "stoneware";

// Production: bind and listen.
const { server, workers } = await serve();
console.log(`serving on ${server.url} across ${workers} process(es)`);

// A test, a script, an embed: no port at all.
const app = await createApp({ root: "/path/to/project" }, { dev: true });
const response = await app.fetch(new Request("http://localhost/about"));

serve() returns the app alongside the Bun server, the number of processes actually serving the port, and a supervisor when clustering is in play. You do not normally write either of these — stoneware start runs the generated entry, which calls serve() for you.

renderToString

Renders a tree to HTML outside a request. This is a fragment, not a document — no doctype, no head, no island scripts — so it is for email bodies, RSS items and snippets rather than for pages.

import { renderToString } from "stoneware";

const { html, islands } = renderToString(<Summary post={post} />);
await sendEmail({ to: subscriber, html });

It returns an object, not a string: html, plus islands — every island the tree contained, in document order. A page render uses that second half to decide which chunks to reference. Rendering a fragment by hand, you normally want only html, and an island in something you are emailing is a sign the tree is the wrong one.

defineConfig, buildCSP, DEFAULT_CSP

stoneware.config.tstsx
import { defineConfig } from "stoneware";

export default defineConfig({
  port: 3000,
  trustProxy: "proto",
  csp: {
    // Added to the default policy, not replacing it: 'self' survives.
    imgSrc: ["https://cdn.example.com"],
    connectSrc: ["https://api.example.com"],
  },
});

defineConfig is an identity function — it exists for type inference, and a plain object export works identically. buildCSP and DEFAULT_CSP are exported so you can print the policy you will actually send, which is worth doing once before arguing with a browser console.

terminalsh
bun -e 'import { buildCSP } from "stoneware";
  console.log(buildCSP({ imgSrc: ["https://cdn.example.com"] }))'

# default-src 'self'; script-src 'self'; ... img-src 'self' data: https://cdn.example.com; ...
Naming a directive the default policy has no entry for — frameSrc, workerSrc — creates it seeded with 'self', because that is what it was inheriting from default-src. A source containing a semicolon, comma or whitespace throws, so a value read from an environment variable cannot append a directive nobody asked for.

consoleObserver and formatEvent

Nothing is logged per request unless you ask. consoleObserver() is the built-in one line per request; formatEvent() is that same line, exported so you can keep the format and send it somewhere other than the console.

stoneware.config.tsts
import { defineConfig, consoleObserver, formatEvent } from "stoneware";

export default defineConfig({
  // The built-in. assets: true also logs images and chunks — off by default,
  // because one page load is one page request and then everything on it.
  observe: consoleObserver({ assets: true }),
});

// Or take the event apart yourself:
observe: (event) => {
  metrics.timing("http.request", event.durationMs, {
    route: event.route ?? "unmatched",
    status: String(event.status),
  });
  if (event.error) Sentry.captureException(event.error);
  if (event.caught) event.caught.forEach((e) => Sentry.captureException(e));
  logstream.write(formatEvent(event) + "\n");
},

event.route is the pattern — /blog/[slug], not /blog/hello — which is what you want as a metrics dimension. event.caught carries errors a Boundary absorbed: the request succeeded with a 200 and a fallback, so these would otherwise never reach a reporting backend. An observer that throws is reported once and the request is served normally; a broken logger must not turn a 200 into a 500.

stoneware/signals

A pass-through to @preact/signals-core that deliberately adds nothing. Importing through it rather than from the upstream package keeps the dependency replaceable without every island changing its imports.

islands/Cart.tsxtsx
import { signal, computed, effect, batch } from "stoneware/signals";

const items = signal<Item[]>([]);
const total = computed(() => items.value.reduce((n, i) => n + i.price, 0));

effect(() => {
  localStorage.setItem("cart", JSON.stringify(items.value));
});

export default function Cart() {
  function addTwo(a: Item, b: Item) {
    // One update, one re-render, instead of two.
    batch(() => {
      items.value = [...items.value, a];
      items.value = [...items.value, b];
    });
  }

  return <p>{total} in {items.value.length} items</p>;
}

A signal declared at module scope is shared by every island that imports the module — that is how two islands on a page talk to each other. Declared inside the component function, it is per-instance. untracked() reads a signal without subscribing to it; Signal and ReadonlySignal are the types.

A module-scope signal on the server is shared between requests, not per-visitor. The dev server watches for a value that changed between renders and warns. See islands.

stoneware/client

The hydration runtime. The build wires this up for you and a normal project never imports it — it is here for hydrating something the framework did not put on the page.

import { hydrate, mountTree, startLazyHydration } from "stoneware/client";

// What a generated island entry does: name the island, hand over the
// component. Props come from the JSON payload the server emitted, and every
// instance on the page that is ready for it is activated.
hydrate("Counter", Counter);

// Build a detached tree and get a disposer for its effects.
const { fragment, dispose } = mountTree(<Badge count={count} />);
container.append(fragment);
dispose();

// Start watching for client:visible / client:idle / client:media markers.
startLazyHydration();

hydrate() is safe to call twice — an instance already mounted is skipped — which is what lets a chunk be re-imported. mountTree() returns a DocumentFragment rather than mounting into a parent, so you decide where it goes; dispose() tears down the effects it created.

Types

the ones you will actually importts
import type {
  PageProps,          // params, request, url, locals — a page
  ActionContext,      // the same four — an action handler
  ErrorPageProps,     // PageProps + status, message, error — _404 / _500
  HeadFn,             // the shape of a route's head export
  MiddlewareContext,  // request, url, locals — _middleware.ts
  Locals,             // yours to declare; see middleware
  RequestEvent,       // what an observer receives
  StonewareConfig,    // what a config file may contain
  ImageProps,
  SEOOptions,
  SitemapEntry,
} from "stoneware";

Child, Component, PageComponent, VNode and RawHTML are also exported, for code that builds or accepts markup generically rather than rendering it directly.

What not to import

Router, buildDocument, buildIslands, discoverIslands, loadIslands, buildIslandRegistry, resolveConfig, loadConfigFile, generateToken, verifyRequest, isSafeMethod and CLIENT_ASSET_PREFIX are the CLI's own plumbing. They are exported because the CLI is an ordinary consumer of the package and nothing more is meant by it. Building on them is building on internals, and they change without a major version.

One exception worth knowing: generateToken(config) mints a CSRF token from a resolved config rather than from a live render, which is occasionally what a test wants. It skips the half where the page has to render the field, so it is a weaker assertion than reading a token out of real HTML.

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