stoneware

Documentation

Components

Plain functions that compose, take children and nest — and the one rule about async that follows from rendering to a string.

Stoneware builds UI from components. A component is a function that takes props and returns markup — no class to extend, no hook to call, nothing to register. If you have written JSX before, this is the half of it you already know.

lib/ui/Card.tsxtsx
export function Card({ title, children }) {
  return (
    <article class="card">
      <h3>{title}</h3>
      {children}
    </article>
  );
}
routes/index.tsxtsx
import { Card } from "../lib/ui/Card.tsx";

export default function Home() {
  return (
    <Card title="Hello">
      <p>Body</p>
    </Card>
  );
}
what the server sendstxt
<article class="card"><h3>Hello</h3><p>Body</p></article>
"No component model" in the design notes means no stateful component runtime — no hooks, no lifecycle, no classes, no context. It has never meant you cannot build UI out of components. This page is the part that works.

Composition

Components nest to any depth, take children, accept defaults, and spread the props you did not name. Markup itself is a value, so a component can take a prop that is an element.

the patterns, all of which worktsx
function Stack({ gap = "1rem", children }) {
  return <div class="stack" data-gap={gap}>{children}</div>;
}

function Badge({ tone = "plain", ...rest }) {
  return <span class={`badge badge--${tone}`} {...rest} />;
}

<Stack gap="2rem">
  <Card title="A">x</Card>
  <Card title="B">y</Card>
</Stack>

<Badge tone="glazed" id="b1" aria-label="New" />

{/* markup as a prop, not just as children */}
<Card title={<em>rich</em>}>body</Card>
renderedtxt
<div class="stack" data-gap="2rem">
  <article class="card"><h3>A</h3>x</article>
  <article class="card"><h3>B</h3>y</article>
</div>

<span class="badge badge--glazed" id="b1" aria-label="New"></span>

<article class="card"><h3><em>rich</em></h3>body</article>

Lists, conditionals and nothing

Arrays render in order. A falsy branch renders nothing at all rather than the word "false", and a component may return null when it has nothing to contribute.

<ul>{items.map((item) => <li>{item.name}</li>)}</ul>

<div>
  {isAdmin && <AdminBar />}
  {user ? <Profile user={user} /> : <SignIn />}
</div>

function Empty() {
  return null;   // renders as an empty string
}
There is no key prop to remember. Keys exist so a reconciler can match nodes between renders, and there is no second render here — the list is walked once and appended to a string.

Fragments

Return several elements without a wrapper. This matters more than usual for islands, which must render exactly one root element — a fragment is how you find out you need a wrapper there.

function Meta() {
  return (
    <>
      <dt>Published</dt>
      <dd>2026-08-20</dd>
    </>
  );
}

The one rule: only a route may be async

Rendering walks the tree to a string in a single synchronous pass, so there is no point at which a nested component's promise could be awaited. A route's default export is different — the server awaits that one call before rendering begins, which is the one place a promise can resolve.

routes/blog/[slug].tsx — the shape that workstsx
export default async function Post({ params }: PageProps) {
  const post = await getPost(params.slug);   // fetch here
  if (!post) notFound();

  return (
    <Layout>
      <Article post={post} />        {/* pass it down as props */}
    </Layout>
  );
}

Make a nested component async and the render stops and tells you, naming the component and the path down to it:

the actual errortxt
A component returned a promise while rendering.

  in <Reviews>
  in <Layout>

Only a route's default export may be async - the server awaits that one call
before rendering begins. A component nested inside JSX cannot be, because
rendering never awaits.

Fetch in the route and pass the result down as props.
This is a constraint worth understanding rather than working around. Data fetching that happens inside a deeply nested component is how a page acquires a waterfall it cannot see; hoisting it to the route makes every query the page needs visible in one function.

Where components live

  • lib/ — shared components. This is the normal home for anything used by more than one route. Nothing under lib/ ships JavaScript unless an island imports it.
  • routes/ — a route's default export is a component, and a route file may define local components beside it. Never ships JavaScript.
  • islands/ — components that hydrate. The only place client JS originates.

A component and its stylesheet sit in the same folder: lib/ui/Card.tsx beside lib/ui/Card.css. The build finds the CSS by location, so there is nothing to import and deleting the folder deletes both.

Components and islands compose

An island nested inside ordinary components is still an island. The hydration marker lands on the island's own root element however deep it sits, so a layout can wrap interactive parts without knowing anything about hydration.

<Layout>
  <Panel>
    <Counter />       {/* islands/Counter.tsx */}
  </Panel>
</Layout>
rendered — the marker is on the button, not on a wrappertxt
<main><section><div class="panel">
  <button class="c" data-stoneware-island="Counter" data-stoneware-id="stoneware-0">0</button>
</div></section></main>

What there is no equivalent of

  • useState and friends. A component runs once, on the server. State that changes in the browser lives in an island, in a signal.
  • useEffect and lifecycle. There is nothing to mount and nothing to clean up — the output is a string.
  • Context providers. Pass props, or read from a module. Middleware puts per-request values on locals, which every route and action receives.
  • Class components and forwardRef. There is no instance and no DOM node to forward to on the server.
  • memo. Rendering a 14 KB document takes about 21 microseconds; there is nothing to memoize.

If a component needs to change after the page has loaded, that is the definition of an island. See islands for how state and hydration work there.

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