Tutorial 20 of 54 · for anyone coming from Next.js · Markdown

Port a Next.js route

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.

Before you start

Install from crates.io. You need no Node and no package manager.

cargo install snapfire_compiler
cargo install snapfire_fsr_cli
fsr --version

Every command and screenshot on this page was captured with fsr 0.x.

Start the app#

$ 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:

$ 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:

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.

config/app.toml
[clients.shop]
transport = "mock"
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#

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

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

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#

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

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:

$ 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.

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

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:

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:

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#

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

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

app/routes/product/[id]/page.tsx
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#

$ 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#

NextFSR
app/page.tsx async componentpage.loader.ts plus page.tsx
fetch() and a casta client generated from OpenAPI or Protobuf
"use server"action() plus a schema file
revalidatePathnothing, the loader runs again
"use client"an island, browser or server
generateStaticParamsnothing, it's worked out for you
export const revalidatex-sf-cache on the operation for the data; a rebuild refreshes a prerendered page
unstable_cache with tagsx-sf-cache with tags and x-sf-writes on the method that changes the data, see 100

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 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:

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:

$ 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:

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;
}
$ 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.