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:
"/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.
[clients.shop]
transport = "mock"{
"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
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>
);
}// 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:
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
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>
);
}// 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.
"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}`);
}// 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:
export interface Session {
cart: Record<string, bigint>;
}
export const defaults: Session = {
cart: {},
};The page calls it through a generated object, typed both ways:
export const actions = {
product: {
$id: {
addToCart: call("product.$id.addToCart") as unknown as (input: AddToCart) => Promise<{ count: bigint | number }>,
},
},
};Port the client component
"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>
);
}// 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:
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
| 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 | nothing, a rebuild refreshes it |
unstable_cache with tags | nothing, 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.