---
title: "Port async components from Next.js"
audience: "anyone coming from Next who writes async function Page() and lazy()"
description: "You'll write a report page the way Next's App Router teaches, with an async component that awaits its data. fsr will refuse it and tell you where the await…"
snippet_caption: ""
snippet: ""
---

import { Install } from "@src/ui/Install";

You'll write a report page the way Next's App Router teaches, with an `async` component that awaits its data. fsr will refuse it and tell you where the await goes. Then you'll split the chart into a component that loads on demand with `React.lazy`. fsr still renders it on the server, so the first paint has the finished chart.

<Install captured="0.x" />

## Two things called an async component

Next uses "async component" for a component that awaits while it renders. React and Vue use it for a component whose code loads on demand, `lazy()` and `defineAsyncComponent()`. fsr refuses the first and supports the second. This tutorial does both, in that order.

## The chart

```shell-session
$ fsr new sales --with react
```

A bar chart with one button that sorts it, in `app/src/ui/Bars.tsx`:

```tsx title="app/src/ui/Bars.tsx"
import { useState } from "react";

export type Row = { region: string; total: number };

export default function Bars({ rows }: { rows: Row[] }) {
  const [sorted, setSorted] = useState(false);
  const shown = sorted ? [...rows].sort((a, b) => b.total - a.total) : rows;
  const max = Math.max(...rows.map((r) => r.total));
  return (
    <div className="bars">
      <button onClick={() => setSorted(!sorted)}>{sorted ? "Original order" : "Sort by total"}</button>
      {shown.map((r) => (
        <div key={r.region} className="bar">
          <span>{r.region}</span>
          <span className="fill" style={{ width: `${(r.total / max) * 100}%` }}>
            {r.total}
          </span>
        </div>
      ))}
    </div>
  );
}
```

A chart section that loads `Bars` on demand, in `app/src/ui/Chart.tsx`:

```tsx title="app/src/ui/Chart.tsx"
import { lazy } from "react";
import type { Row } from "@src/ui/Bars";

const Bars = lazy(() => import("@src/ui/Bars"));

export default function Chart({ rows }: { rows: Row[] }) {
  return (
    <section>
      <h2>Sales by region</h2>
      <Bars rows={rows} />
    </section>
  );
}
```

There's no `<Suspense>` around `<Bars>`. In a browser-only React app you'd need one, with a fallback to show while the module downloads. You'll see below why fsr doesn't.

The data comes from an async function, standing in for a database or an API, in `app/src/sales.ts`:

```ts title="app/src/sales.ts"
import type { Row } from "@src/ui/Bars";

export async function salesByRegion(): Promise<Row[]> {
  return [
    { region: "North", total: 420 },
    { region: "South", total: 910 },
    { region: "East", total: 260 },
    { region: "West", total: 640 },
  ];
}
```

Some styling, at the end of `app/styles/app.css`:

```css title="app/styles/app.css"
.bars {
  display: grid;
  gap: 8px;
}

.bar {
  align-items: center;
  display: grid;
  gap: 12px;
  grid-template-columns: 80px 1fr;
}

.fill {
  background: #2f80ed;
  border-radius: 4px;
  color: #fff;
  padding: 2px 8px;
}
```

## The page the Next way

This is how you'd write it in the App Router: the page awaits its own data.

```tsx title="app/routes/report/page.tsx"
import Chart from "@src/ui/Chart";
import { salesByRegion } from "@src/sales";

export default async function Report() {
  const rows = await salesByRegion();
  return (
    <article>
      <h1>Quarterly report</h1>
      <Chart rows={rows} />
    </article>
  );
}
```

There's no `"use client"` anywhere. `Bars` has a button with state, so it has to run in the browser, but the build works that out from the component itself.

```shell-session
$ fsr dev sales/app
routes/report/page.tsx:4:16: an `async` component; what it awaits belongs in the route's loader, `page.loader.ts`, which runs before the page renders
dev: waiting for changes
```

The build stops at the page rather than sending it to the browser. `fsr dev` waits for you to fix it.

## Move the await

Do what it says. The await goes into a loader beside the page:

```ts title="app/routes/report/page.loader.ts"
import type { Ctx } from "@snapfire/fsr";
import { salesByRegion } from "@src/sales";

export async function load(_ctx: Ctx<"/report">) {
  return { rows: await salesByRegion() };
}
```

The page takes the data as props and stops being async:

```tsx title="app/routes/report/page.tsx" {1,4}
import type { ReportProps } from "@generated/client";
import Chart from "@src/ui/Chart";

export default function Report({ rows }: ReportProps) {
  return (
    <article>
      <h1>Quarterly report</h1>
      <Chart rows={rows} />
    </article>
  );
}
```

`ReportProps` is generated from what the loader returns, so the page is typed against it without you writing the type. Save both files and the server starts.

The split isn't style. Every loader under a route starts before anything renders, so a layout's data and a page's data load side by side. An await inside a render runs in tree order instead, each level waiting for its parent. That's the waterfall Next apps fix with `Promise.all` and preloading. A render that only reads its props can also be cached by what it read. A render that fetches mid-way can't.

## The lazy part renders on the server

Open `http://127.0.0.1:3000/report` and view the page source. The bars are there before any script runs:

```shell-session
$ curl -s http://127.0.0.1:3000/report | grep -o '<div class="bar"><span>South</span>[^/]*'
<div class="bar"><span>South</span><span class="fill" style="width:100%">910<
```

fsr followed `import("@src/ui/Bars")` at build time and lowered `Bars` like any other component. So the server rendered it, `lazy()` or not. The first paint has the finished chart. Nothing needs a fallback, because there's never a moment with nothing to show.

The module still loads on demand. Open the network panel and load `/`: `Bars.js` isn't requested. Load `/report` and it arrives once the page is up, when `Bars` mounts over the markup it already has. `Chart.js` never arrives at all: `Chart` only renders a heading and places `Bars`, so the build kept it on the server and cut the island at `Bars`, the part with state. Click "Sort by total" and South jumps to the top.

## Try it

Make `Chart` async instead of the page: `export default async function Chart` with an `await salesByRegion()` inside. It's refused the same way, at the line of the `async`. Any component that awaits while it renders is refused, not just pages.

Then make the whole chart one island instead of letting the build pick. Wrap it in the page:

```tsx title="app/routes/report/page.tsx" {2,9-11}
import type { ReportProps } from "@generated/client";
import { Island } from "@snapfire/fsr-authoring/template";
import Chart from "@src/ui/Chart";

export default function Report({ rows }: ReportProps) {
  return (
    <article>
      <h1>Quarterly report</h1>
      <Island>
        <Chart rows={rows} />
      </Island>
    </article>
  );
}
```

Reload `/report` with the network panel open. `Chart.js` now arrives as well as `Bars.js`, since the heading is part of the island too. The page looks the same; only what the browser downloads changed. Take the `Island` back out.
