Tutorial 13 of 21 · for anyone coming from Next.js

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. You don't need Next installed, because the Next code is printed here.

Before you start

Straight from crates.io. No Node, 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
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 is not what you want when the whole point is porting React components across.

Point it at your API's document

You don't write this. 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

The filename is the binding. clients/<name>.openapi.json is what [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.

You never run a generator. fsr build reads the document, writes the contract and writes the typed methods.

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

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. That is the only convention you have to care about.

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
[clients.shop]
transport = "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";

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:

ts
export interface Product {
  id: string;
  name: string;
  blurb: string;
  price_cents: bigint;
}

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

The OpenAPI integer became a bigint, not 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 is 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>
  );
}

Not async. No fetch. The component is a function of its props, which is what lets the build 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
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 with a directive. An FSR action is a declared input plus a function. The input 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";
import type { ActionCtx } from "@snapfire/fsr";
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 };
});

Three differences worth naming. 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, rather than arriving as a positional argument nobody validated. And there's 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
export interface Session {
  cart: Record<string, bigint>;
}

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

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

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

Identical apart from the deleted directive. What changes is where you place it. That moves out of the component and into the caller:

tsx
import { Island } from "@snapfire/fsr-client/react";
import { Quantity } from "@src/ui/Quantity";

<Island when="load" mode="server">
  <Quantity initial={1} />
</Island>

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

Build it

$ fsr build shop/app
routes    /                      routes
          /product/{id}          routes/product/[id]
sources   product.$id            lowered     routes/product/[id]/page.loader.ts
rendered  routes/product/[id]/page.tsx#default lowered
          src/ui/Quantity.tsx#Quantity       lowered
islands   src/ui/Quantity.tsx#Quantity       server      2 handlers
actions   product.$id.addToCart  lowered     routes/product/[id]/actions.ts
services  shop                   http        clients/shop.openapi.json
schemas   AddToCart              schemas/cart.ts
          Session                schemas/session.ts
hoisted   routes/product/[id]/page.tsx#default 2 subtrees
typecheck tsc 7.0.2 from cache, clean

Run fsr dev shop/app and open /product/1. Server-rendered page, working stepper, no component code in the browser, no backend running.

What went away

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 revalidatenothing, a rebuild refreshes it
unstable_cache with tagsnothing, see 100

generateStaticParams is gone. 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.

params.id is still params.id. It's typed from the route pattern in Ctx<"/product/{id}">.

What to watch for

Your loader can't use the whole language. new, a custom hook on the render path, a Date: all refused. 120 is about reading those messages.

A "use client" boundary in Next spreads downward. An FSR island doesn't, but an unlowerable call spreads upward: one bad call in a leaf marks the importing page client, then that page stops registering its islands. Read the client rows.

There's no middleware.ts at the edge. Middleware in FSR runs in the host, in Rust.

Left behind

No node_modules, no next.config.js, no Node on the server. The API types come from the document rather than being written by hand. The caching settings are gone because the build derives them.

Next up: 090. Add a library without npm.

Built with SnapFire FSR. Pure Rust runtime, zero Node.js on the server.

Proudly Created by Excerion Sun LLC