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.
$fsr new shop --with react...added react react/react.bundle.mjs 10471 bytesadded react/jsx-runtime react/jsx-runtime.bundle.mjs 1493 bytesadded 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.
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:
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.
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.
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/apptypecheck: tsc 7.0.2 from cache, 1 errorroutes/product/[id]/page.tsx(2,15): error TS2724: '"@generated/client"' has no exported member named 'ProductProps'. Did you mean 'ProductIdProps'?
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.
// schemas/cart.tsexportinterfaceAddToCart{product_id:string;quantity:bigint;}// routes/product/[id]/actions.tsimport{action,fail}from"@snapfire/fsr/host";importtype{ActionCtx}from"@snapfire/fsr/host";importtype{AddToCart}from"@schemas/cart";exportconstaddToCart=action(async({input,session}:ActionCtx<AddToCart>)=>{if(input.quantity<=0n)fail("invalid","quantity must be positive");constheld=session.cart[input.product_id]??0n;session.cart={...session.cart,[input.product_id]:held+input.quantity};constcount=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:
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.
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
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}">.
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:
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: