Move a Next.js app with fsr migrate
You'll run fsr migrate over a small Next.js app with a layout, metadata, a server action, next/link and next/image, read what it prints and finish the lines it leaves for you. 320 ports a route by hand. This one lets the command do the moving.
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.
The Next.js app#
The app is a guestbook: a home page with a form, an about page and a layout with a logo. fsr migrate reads the source alone, so you don't need node_modules or a Next build. If you don't have an app handy, write these files.
;
;
;
;
;
"use server";
;
;
;
Put any PNG at guestbook-next/public/logo.png.
Run fsr migrate#
The first directory is the Next app, which is read and never written. The second is a new FSR project:
Each line has a job:
fromnames what it found: an App Router app.wrotecounts the files it moved into the new project, which is scaffolded the wayfsr new --with reactscaffolds one. Theaddedrows are that React.notenames a line it could not rewrite. Each one is yours to finish.nextlists the commands to run, in order.
Read what it wrote#
The routes moved to app/routes/ and public/ moved to app/public/. The root layout lost <html> and <body>, since the host writes the document. next/link and next/image became FSR components:
;
;
export const metadata became meta in a loader beside the route. The layout's became the site-wide title and description:
;;The "use server" module became the route's actions.ts. The action read its FormData only as formData.get(...), so those keys became its input type, every field a string since that is what a form posts. revalidatePath is gone, since an action revalidates its route:
;
;
;
;Vendor the packages#
fsr add vendors what the app imports into app/vendor/ and names it in the import map. Run the line fsr migrate printed:
Run fsr check#
fsr check builds and typechecks without writing anything:
That is the line the note named. The page still says <form action={sign}> but sign is no longer imported. The build also lifted the form into an island that renders in the browser, since it could not follow the name.
Finish the form by hand#
An FSR form posts to the action's route, /_sf/action/<id>. An action in app/routes/actions.ts belongs to the root route, so its id is $root.sign. The post carries a CSRF token, which every page reads as the csrf_token prop:
;
fsr check guestbook/app now prints nothing and exits 0.
Run it#
Open /. The tab reads "Guestbook" with the description from the old metadata. The logo loads from app/public/logo.png. Click About and the title becomes "About the guestbook". Sign the book as Ada. The form posts, the browser lands back on / and the dev log shows the action's console.log:
2026-10-24T11:53:46.184500Z INFO fsr::console: Ada signed: Hello there
What is left by hand#
fsr migrate notes each line it leaves. In this app that was the one form. In a bigger app, expect these:
| Next.js | What to do |
|---|---|
a "use server" function used as a form's action or passed around | post to /_sf/action/<id> or call actions.<route>.<name>(input) from @generated/client |
a fetch to a backend, a database client, a server-only process.env | a client under [clients] with its document, see 375 |
cookies(), headers(), draftMode() | read session, identity and query in the loader |
revalidate | [cache] in config/app.toml |
useRouter, usePathname, useSearchParams | params and query from the loader, navigate from @snapfire/fsr/client |
| a title template | [document] title_template |
next/font/local | [fonts.<key>] with files |
Next up: 330. Add a JavaScript library.