In Jeasx, every component can be async. There are no loaders, hooks or special data APIs: you simply await your data where you need it. Every JSX response is streamed to the browser while it renders, so everything above a slow component is sent right away.

Async components

Mark a component as async and await anything inside it, e.g. fetch, a database query or a file read:

// src/components/LatestPosts.jsx
export default async function LatestPosts({ limit = 5 }) {
  const response = await fetch(`https://dummyjson.com/posts?limit=${limit}`);
  const { posts } = await response.json();

  return (
    <ul>
      {posts.map((post) => (
        <li>{post.title}</li>
      ))}
    </ul>
  );
}

Async components are used like any other component, without any wrapper:

// src/[index].jsx
import LatestPosts from "./components/LatestPosts";

export default function () {
  return (
    <main>
      <h1>Welcome</h1>
      <LatestPosts limit={3} />
    </main>
  );
}

You can also pass promises directly as children or attribute values, e.g. <p>{loadGreeting()}</p> or <img src={loadAvatarUrl(user)} />. They are awaited automatically.

Where to load data

You have two good options, and you can mix them freely:

  • In the route: load all data up front, decide about status codes (e.g. 404) and pass the data down as props. This keeps components simple and easy to test.
  • In the component: let a component load its own data. This is handy for self-contained parts like a sidebar with the latest posts that appear on many pages.

Data that many components need (e.g. the current user or site settings) can be loaded once in a guard and put into the context via this, so you don't have to pass it through every component.

Why no data loaders?

Frameworks like Next.js or Remix need special APIs to load data, because components may also run in the browser. In Jeasx, everything runs on the server, so a plain await is all it takes.

Loading data in parallel

Async components render one after another, in the order of your JSX: a component starts only when everything before it has finished. Two components that each wait one second take two seconds in total.

To load data in parallel, start all requests first and pass the promises down, either as children or as props:

export default function ({ request }) {
  // Both requests start right away and run in parallel.
  const user = loadUser(request.query.id);
  const orders = loadOrders(request.query.id);

  return (
    <main>
      <UserCard user={user} />
      <OrderList orders={orders} />
    </main>
  );
}

async function UserCard({ user }) {
  const { name } = await user;
  return <h2>{name}</h2>;
}

If you need all values at once, Promise.all works as usual: const [user, orders] = await Promise.all([loadUser(id), loadOrders(id)]);. Starting promises early is safe: if one fails before it is rendered, the error is reported where the promise is rendered or awaited.

How a page is streamed

You don't have to do anything to turn streaming on. Jeasx renders your JSX in order, from top to bottom, with jsxToStream from jsx-async-runtime:

  • Whenever rendering has to wait (for an async component, a promise or the next item of an async iterable), the markup rendered so far is sent to the browser as a chunk.
  • Large pages are sent in chunks of about 16 KB, even if nothing is async, so the complete page never has to be held in memory.
  • Rendering pauses while a slow client catches up, and stops when the client disconnects.

So the browser can already load your CSS and show the header while data is still loading.

Put slow parts last

As async components run one after another, a slow component delays everything below it. So put slow parts as late in the page as possible, and start slow requests early to load them in parallel:

export default function ({ request }) {
  // Start the slow request right away, so it runs while the page renders.
  const reviews = loadReviews(request.query.id);

  return (
    <Layout title="Product">
      {/* Sent to the browser immediately */}
      <ProductHeader id={request.query.id} />
      {/* Sent as soon as the reviews have arrived */}
      <Reviews reviews={reviews} />
    </Layout>
  );
}

Streaming lists

Iterables (e.g. Set or generators), async iterables and ReadableStream can be used as children. They are rendered in order as items arrive. Async generators let you stream long lists item by item, e.g. rows from a paginated API or a database cursor:

async function* Users() {
  for await (const user of db.users.find()) {
    yield <li>{user.name}</li>;
  }
}

export default function () {
  return (
    <ul>
      <Users />
    </ul>
  );
}

If the client disconnects, the generator is stopped like a for await loop on break, so its finally block runs and can close the cursor. A ReadableStream (e.g. a fetch() body) can be rendered as a child the same way. Its text is escaped like any other string.

Databases

Jeasx doesn't come with a database layer, so use whatever you like (e.g. Drizzle, Kysely, Prisma or node:sqlite). Put the connection into a separate module and import it wherever you need it. Jeasx bundles each route into its own file, so a module imported by several routes exists once per route. Store the connection on globalThis, so all routes share a single connection (or pool), even across hot reloads during development:

// src/services/db.js
import { DatabaseSync } from "node:sqlite";

// Shared by all routes, as each route has its own copy of this module.
globalThis.db ??= new DatabaseSync(process.env.DATABASE_PATH ?? "data.db");
export const db = globalThis.db;

export function findBook(id) {
  return db.prepare("SELECT * FROM books WHERE id = ?").get(id);
}

Because the file name isn't enclosed in brackets, it is never exposed as a route. Keep credentials in environment variables, never in your code. The tutorial shows a complete example of storing and loading data.

Status codes, headers and missing data

The status code and headers are sent with the first chunk. Set them in your guards or route before you return JSX, and decide about redirects or a 404 there, too. Calling reply.status() or reply.header() in a nested component has no effect once streaming has started.

So load data that decides about the status code in the route. If nothing is found, set the status and return nothing, so the next matching route (e.g. your [404] page) answers:

// src/books/[...path].jsx
import { findBook } from "../services/db";

export default function ({ request, reply }) {
  const book = findBook(request.path.split("/").at(-1));
  if (!book) {
    reply.status(404);
    return;
  }
  return <BookDetails book={book} />;
}

Errors and timeouts

Your Fastify error handler only runs for errors thrown before the first chunk is sent. Once streaming has started, an error thrown by an async component cuts the page off. So load data that can fail in the route, before returning JSX, and wrap optional parts (e.g. a widget calling an unreliable API) in an error boundary that shows a fallback.

A request that never answers keeps the page waiting forever. Give external calls a timeout:

const response = await fetch(url, { signal: AbortSignal.timeout(5000) });

Caching

Data that rarely changes doesn't have to be loaded for every request. A simple in-memory cache on globalThis is often all you need. A plain module variable would exist once per route, so each route would fetch the data again:

// src/services/settings.js
export function loadSettings() {
  // Keep the promise, so parallel requests share a single fetch.
  globalThis.settings ??= fetch("https://cms.example.com/settings").then((r) => r.json());
  return globalThis.settings;
}

Use distinctive names for such keys, so they don't clash with other globals. Remember that such a cache lives as long as the server process and isn't shared between multiple instances. If it should refresh, store a timestamp next to it. For whole pages, set a cache-control header (see Requests & Responses) and let a CDN or reverse proxy do the caching.

What turns streaming off

Some features need the complete markup at once. They still work, but the affected part of the page is sent as a single chunk:

  • A this.jsxToString override renders everything below it to a string (see transforming JSX). In a root guard, it affects the whole page.
  • A this.responseHandler which reads the stream into a string, e.g. to format or minify the HTML (see FAQ).
  • An error boundary renders its children to a string, so it can show a fallback instead.

Streaming other content

Routes can also return a ReadableStream, a Node.js stream or a Response (e.g. from fetch()), which is sent as is. A Response brings its own status and headers. For streams, set a content type with reply.type(), otherwise they're sent as text/html. This is useful for proxying an API, large exports or Server-Sent Events.

Proxies and compression

Streaming only helps if nothing between the server and the browser buffers the response. Some reverse proxies wait for the complete response by default (e.g. nginx, which you can tell via proxy_buffering off or an X-Accel-Buffering: no header). Compression can also hold back small chunks until enough data has piled up.

Check that streaming works

Open the network tab of your browser and check that the first bytes of a slow page arrive early. If the whole page arrives at once, something between the server and the browser buffers the response.

See it in action