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, and the page is streamed to the browser while it renders.

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.

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.

Streaming

Pages are streamed to the browser while they render. Everything above a slow component is sent right away, so the browser can already load your CSS and show the header while data is still loading. Put slow parts as late in the page as possible.

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>
  );
}

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.

Handling missing data

Load data that decides about the status code in the route, before you return any JSX. 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} />;
}

Once streaming has started, the status code has already been sent and can't be changed anymore. So don't decide about a 404 inside a nested component.

Errors and timeouts

Errors thrown before the page starts streaming are handled by your Fastify error handler. Errors thrown later in an async component cut the page off, so wrap risky parts (e.g. a widget calling an unreliable API) in an error boundary that shows a fallback instead (see Error handling).

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.

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.