---
title: "Port a Next.js route"
audience: "anyone coming from Next.js"
description: "You'll port a Next product page to FSR: the data fetch, the server action and the client component. You end up with a running app whose API client, request…"
snippet_caption: ""
snippet: ""
---

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

You'll port a Next product page to FSR: the data fetch, the server action and the client component. You end up with a running app whose API client, request types and response types were all generated from an OpenAPI document. The Next code is printed here, so you can follow along with only FSR installed.

This tutorial ports one route by hand. If you have a whole Next app to move, start with [325. Move a Next.js app with fsr migrate](https://snapfirers.com/fsr/learn/325-move-a-next-js-app-with-fsr-migrate).

<Install captured="0.x" />

## Start the app

```shell-session
$ fsr new shop --with react
...
added     react                        react/react.bundle.mjs  10471 bytes
added     react/jsx-runtime            react/jsx-runtime.bundle.mjs  1493 bytes
added     react-dom/client             react-dom/client.bundle.mjs  215154 bytes
...
next      fsr dev shop/app
```

`--with react` is what vendors React and maps it. A plain `fsr new` writes the same project with no framework, which you don't want here because you're porting React components.

## Point it at your API's document

Your backend already publishes one. FastAPI, Spring, ASP.NET and most Go frameworks serve it at a well-known path, so grab it:

```shell-session
$ curl -s https://api.shop.test/openapi.json -o shop/app/clients/shop.openapi.json
```

`clients/<name>.openapi.json` is the file `[clients.<name>]` looks for, so this one is reachable as `services.shop`. If your backend speaks gRPC, drop its `.proto` in as `clients/shop.proto` instead and nothing else in this tutorial changes.

`fsr build` reads the document and writes the contract and the typed methods itself.

What FSR takes from it is the operation id and the schemas. For the endpoint this tutorial ports, that's:

```json title="app/clients/shop.openapi.json"
"/products/{id}": {
  "get": {
    "operationId": "getProduct",
    "parameters": [
      { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }
    ],
    "responses": {
      "200": { "content": { "application/json": {
        "schema": { "$ref": "#/components/schemas/Product" } } } }
    }
  }
}
```

`operationId` becomes the method name and no other naming convention applies.

## Run it with no backend

You probably don't have that API running while you follow along, so mock it. The contract still comes from the document, so a method it doesn't declare is refused before the file is even read.

```toml title="config/app.toml"
[clients.shop]
transport = "mock"
```

```json title="app/clients/shop.mock.json"
{
  "getProduct": { "id": "1", "name": "Chore coat", "blurb": "Waxed cotton, four pockets.", "price_cents": 14800 }
}
```

That second file is `app/clients/shop.mock.json`. Give the client a `base_url` and drop the `transport` line when you want the real service.

## Port the fetch

<SideBySide>

```tsx title="Next"
export const revalidate = 3600;

export async function generateStaticParams() {
  const res = await fetch("https://api.shop.test/products");
  const products = await res.json();
  return products.map((p) => ({ id: String(p.id) }));
}

export default async function Product({ params }: { params: { id: string } }) {
  const res = await fetch(`https://api.shop.test/products/${params.id}`);
  const product = await res.json();
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.blurb}</p>
    </article>
  );
}
```

```tsx title="FSR"
// routes/product/[id]/page.loader.ts
import type { Ctx } from "@snapfire/fsr/host";

export async function load({ params, services }: Ctx<"/product/{id}">) {
  const product = await services.shop.getProduct({ id: params.id });
  return { product };
}
```

</SideBySide>

`services.shop.getProduct` is generated. Build once and look at what you got:

```ts title="app/generated/services.d.ts"
export interface Product {
  id: string;
  name: string;
  blurb: string;
  price_cents: bigint;
}

export interface Upload {
  filename: string;
  content_type: string;
  size: bigint;
  bytes: Uint8Array;
}

export interface Services {
  shop: {
    getProduct(args: { id: string; }): Promise<Product>;
  };
}
```

The OpenAPI `integer` became a `bigint` instead of a `number`, so a price in cents past 2^53 survives the wire. Misspell a field and the build stops you. In the Next version `product.blurb` is `any` and a typo shows up as `undefined` in production.

## Port the page

<SideBySide>

```tsx title="Next"
export default async function Product({ params }: { params: { id: string } }) {
  const res = await fetch(`https://api.shop.test/products/${params.id}`);
  const product = await res.json();
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.blurb}</p>
    </article>
  );
}
```

```tsx title="FSR"
// routes/product/[id]/page.tsx
import type { ProductIdProps } from "@generated/client";

export default function Product({ product }: ProductIdProps) {
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.blurb}</p>
    </article>
  );
}
```

</SideBySide>

The component is not `async` and does no fetch. It is a function of its props, so the build can run it in Rust.

Watch the type name. FSR derives it from the route, so `/product/{id}` gives you `ProductIdProps`. It's `{ product: Product }`, the same `Product` the document described. Guess at `ProductProps` and the build says so:

```shell-session
$ fsr build shop/app
typecheck: tsc 7.0.2 from cache, 1 error
routes/product/[id]/page.tsx(2,15): error TS2724: '"@generated/client"' has no exported member named 'ProductProps'. Did you mean 'ProductIdProps'?
```

## Port the server action

A Next server action is a function marked with a directive. In FSR an action pairs a function with a declared input, whose shape is a schema file the build reads.

<SideBySide>

```tsx title="Next"
"use server";

export async function addToCart(productId: string, quantity: number) {
  const session = await getSession();
  session.cart[productId] = (session.cart[productId] ?? 0) + quantity;
  await session.save();
  revalidatePath(`/product/${productId}`);
}
```

```tsx title="FSR"
// schemas/cart.ts
export interface AddToCart {
  product_id: string;
  quantity: bigint;
}

// routes/product/[id]/actions.ts
import { action, fail } from "@snapfire/fsr/host";
import type { ActionCtx } from "@snapfire/fsr/host";
import type { AddToCart } from "@schemas/cart";

export const addToCart = action(async ({ input, session }: ActionCtx<AddToCart>) => {
  if (input.quantity <= 0n) fail("invalid", "quantity must be positive");
  const held = session.cart[input.product_id] ?? 0n;
  session.cart = { ...session.cart, [input.product_id]: held + input.quantity };
  const count = Object.values(session.cart).reduce((n, q) => n + q, 0n);
  return { count };
});
```

</SideBySide>

The session is already loaded and assigning to it persists it, so there is no `save()`. A body that doesn't match `AddToCart` is refused before your function runs. There's also no `revalidatePath`, because the loader runs again on the next request and nothing was cached that could go stale.

You also need `app/schemas/session.ts` to say what a visitor starts with:

```ts title="app/schemas/session.ts"
export interface Session {
  cart: Record<string, bigint>;
}

export const defaults: Session = {
  cart: {},
};
```

The page calls it through a generated object, typed both ways:

```ts title="app/generated/client.ts"
export const actions = {
  product: {
    $id: {
      addToCart: call("product.$id.addToCart") as unknown as (input: AddToCart) => Promise<{ count: bigint | number }>,
    },
  },
};
```

## Port the client component

<SideBySide>

```tsx title="Next"
"use client";
import { useState } from "react";

export function Quantity({ initial }: { initial: number }) {
  const [n, setN] = useState(initial);
  return (
    <div className="qty">
      <button onClick={() => setN(n - 1)}>-</button>
      <span>{n}</span>
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}
```

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

export function Quantity({ initial }: { initial: number }) {
  const [n, setN] = useState(initial);
  return (
    <div className="qty">
      <button onClick={() => setN(n - 1)}>-</button>
      <span>{n}</span>
      <button onClick={() => setN(n + 1)}>+</button>
    </div>
  );
}
```

</SideBySide>

The only change is the deleted directive. The island placement moves into the caller, the product page:

```tsx title="app/routes/product/[id]/page.tsx" {2-3,10-12}
import type { ProductIdProps } from "@generated/client";
import { Island } from "@snapfire/fsr/client/react";
import { Quantity } from "@src/ui/Quantity";

export default function Product({ product }: ProductIdProps) {
  return (
    <article>
      <h1>{product.name}</h1>
      <p>{product.blurb}</p>
      <Island when="load" mode="server">
        <Quantity initial={1} />
      </Island>
    </article>
  );
}
```

`mode="server"` ships no JavaScript for it at all. Without `mode` it hydrates as React the way `"use client"` did.

## Build it

```shell-session
$ fsr build shop/app --verbose
routes    /                      routes
          /product/{id}          routes/product/[id]
layouts   /                      routes/layout.tsx#default
sources   $root                  lowered     routes/page.loader.ts
          product.$id            lowered     routes/product/[id]/page.loader.ts
actions   product.$id.addToCart  lowered     routes/product/[id]/actions.ts
rendered  routes/error.tsx#default           lowered     static
          routes/layout.tsx#default          lowered     static
          routes/not-found.tsx#default       lowered     static
          routes/page.tsx#default            lowered     static
          routes/product/[id]/page.tsx#default lowered     static
          src/ui/Quantity.tsx#Quantity       lowered
islands   src/ui/Quantity.tsx#Quantity       server      2 handlers
hoisted   routes/error.tsx#default           1 subtree
          routes/layout.tsx#default          1 subtree
          routes/not-found.tsx#default       1 subtree
          routes/page.tsx#default            1 subtree
          routes/product/[id]/page.tsx#default 2 subtrees
services  shop                   http        clients/shop.openapi.json
schemas   AddToCart              schemas/cart.ts
          Session                schemas/session.ts
types     @snapfire/fsr/authoring types/@snapfire/fsr/authoring  fsr 0.21.0
          @snapfire/fsr/client   types/@snapfire/fsr/client  fsr 0.21.0
          react                  types/react  @types/react 19.3.0
          react-dom              types/react-dom  @types/react-dom 19.3.0
          csstype                types/csstype  csstype 3.2.3
plan      generated/plan.sexp    3.8 KB written
typecheck tsc 7.0.2 from cache, clean
```

Run `fsr dev shop/app` and open `/product/1`. The page is server-rendered and the stepper works, with the mock answering for the backend. The browser loads `src/main.js` and no module for `Quantity`.

## Next and FSR side by side

| Next | FSR |
|---|---|
| `app/page.tsx` async component | `page.loader.ts` plus `page.tsx` |
| `fetch()` and a cast | a client generated from OpenAPI or Protobuf |
| `"use server"` | `action()` plus a schema file |
| `revalidatePath` | nothing, the loader runs again |
| `"use client"` | an island, browser or server |
| `generateStaticParams` | nothing, it's worked out for you |
| `export const revalidate` | `x-sf-cache` on the operation for the data; a rebuild refreshes a prerendered page |
| `unstable_cache` with tags | `x-sf-cache` with `tags` and `x-sf-writes` on the method that changes the data, see [100](https://snapfirers.com/fsr/learn/100-cache-loaders-and-renders#drop-cached-answers-when-the-data-changes) |

You don't enumerate paths: FSR decides per route whether it can prerender by looking at what your loaders read, then tells you in the boot report `fsr serve --verbose` prints.

`params.id` works as it did in Next and is typed from the route pattern in `Ctx<"/product/{id}">`.

## Other differences from Next

> Your loader and components can't use the whole language. `new` of anything but `Date`, `Map`, `Set`, `URL`, `URLSearchParams` or a class the build reads is residue. So is a hook the build can't follow. [120](https://snapfirers.com/fsr/learn/120-find-out-what-wont-compile) is about reading those messages.

In Next a `"use client"` boundary spreads downward. An FSR island doesn't spread and residue doesn't spread either. A `new WeakMap()` in a leaf component marks only that component `client`. It renders in the browser as a React island while the page around it still lowers. Try it with a price tag:

```tsx title="app/src/ui/Price.tsx"
export function Price({ cents }: { cents: number }) {
  const cache = new WeakMap<object, string>();
  return <span className="price">{cache.has({}) ? "" : String(cents)}</span>;
}
```

Import it in the product page and place `<Price cents={Number(product.price_cents)} />` under the blurb. Then read the `client` rows:

```shell-session
$ fsr build shop/app --verbose
...
          src/ui/Price.tsx#Price             client      src/ui/Price.tsx:2:17
...
client    src/ui/Price.tsx:2:17              `new WeakMap`
          1 module renders in the browser for it
            src/ui/Price.tsx#Price           <Price> routes/product/[id]/page.tsx:11:7
```

Next's `middleware.ts` has a counterpart. `app/middleware.ts` exports a `middleware` function that runs in the host before every request that isn't a static file. A `Response` it returns ends the request there. `await next()` runs the route and hands back its `Response`, which the middleware can change. Returning nothing lets the request continue:

```ts title="app/middleware.ts"
import type { MiddlewareCtx } from "@snapfire/fsr/host";

export async function middleware({ request, next }: MiddlewareCtx) {
  if (request.path === "/products/1") return Response.redirect("/product/1", 308);
  const response = await next();
  response.headers.set("x-frame-options", "DENY");
  return response;
}
```

```shell-session
$ curl -s -o /dev/null -D - localhost:3000/products/1
HTTP/1.1 308 Permanent Redirect
location: /product/1
```

It's written in the same TypeScript a loader is and it runs in the host. There's no separate edge runtime.

Next up: [325. Move a Next.js app with fsr migrate](https://snapfirers.com/fsr/learn/325-move-a-next-js-app-with-fsr-migrate).
