Tutorial 21 of 54 · for anyone with a Next.js App Router app to move · Markdown

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.

guestbook-next/package.json
{
  "name": "guestbook",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build"
  },
  "dependencies": {
    "next": "15.5.0",
    "react": "19.1.0",
    "react-dom": "19.1.0",
    "clsx": "2.1.1"
  }
}
guestbook-next/app/layout.tsx
import type { Metadata } from "next";
import Link from "next/link";
import Image from "next/image";

export const metadata: Metadata = {
  title: "Guestbook",
  description: "Leave a line for the next visitor.",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        <header>
          <Link href="/">
            <Image src="/logo.png" alt="Guestbook" width={32} height={32} />
          </Link>
          <Link href="/about">About</Link>
        </header>
        <main>{children}</main>
      </body>
    </html>
  );
}
guestbook-next/app/page.tsx
import { sign } from "./actions";

export default function Home() {
  return (
    <section>
      <h1>Sign the guestbook</h1>
      <form action={sign}>
        <input name="name" placeholder="Your name" />
        <input name="line" placeholder="Say something" />
        <button type="submit">Sign</button>
      </form>
    </section>
  );
}
guestbook-next/app/actions.ts
"use server";

import { revalidatePath } from "next/cache";

export async function sign(formData: FormData) {
  const name = formData.get("name");
  const line = formData.get("line");
  console.log(`${name} signed: ${line}`);
  revalidatePath("/");
}
guestbook-next/app/about/page.tsx
import clsx from "clsx";

export const metadata = { title: "About the guestbook" };

export default function About() {
  return <p className={clsx("about", { quiet: true })}>One page, one form, one action.</p>;
}

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:

$ fsr migrate guestbook-next guestbook
from      Next.js App Router
wrote     8 files under guestbook
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
note      app/routes/page.tsx:7: `sign` is used other than called; an FSR action is called as `actions.$root.<name>(input)`, a form posts to `/_sf/action/<id>`
next      fsr add guestbook/app clsx@2.1.1
next      fsr check guestbook/app

Each line has a job:

  • from names what it found: an App Router app.
  • wrote counts the files it moved into the new project, which is scaffolded the way fsr new --with react scaffolds one. The added rows are that React.
  • note names a line it could not rewrite. Each one is yours to finish.
  • next lists 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:

guestbook/app/routes/layout.tsx
import { Link } from "@snapfire/fsr/components";
import { Picture as Image } from "@snapfire/fsr/components";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <div>
        <header>
          <Link href="/">
            <Image src="/logo.png" alt="Guestbook" width={32} height={32} />
          </Link>
          <Link href="/about">About</Link>
        </header>
        <main>{children}</main>
      </div>
    </>
  );
}

export const metadata became meta in a loader beside the route. The layout's became the site-wide title and description:

guestbook/app/routes/layout.loader.ts
export const meta = () => ({ title: "Guestbook", description: "Leave a line for the next visitor." });
guestbook/app/routes/about/page.loader.ts
export const meta = () => ({ title: "About the guestbook" });

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:

guestbook/app/schemas/signinput.ts
export interface SignInput {
  name: string;
  line: string;
}
guestbook/app/routes/actions.ts
import { action } from "@snapfire/fsr/host";
import type { ActionCtx } from "@snapfire/fsr/host";
import type { SignInput } from "@schemas/signinput";

export const sign = action(async ({ input }: ActionCtx<SignInput>) => {
  const name = input.name;
  const line = input.line;
  console.log(`${name} signed: ${line}`);
});

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:

$ fsr add guestbook/app clsx@2.1.1
added     clsx                         clsx/clsx.bundle.mjs  464 bytes

Run fsr check#

fsr check builds and typechecks without writing anything:

$ fsr check guestbook/app
rendered  routes/page.island0.tsx#default    client      routes/page.island0.tsx:5:19
client    routes/page.island0.tsx:5:19       `sign` is not bound here; an import the build cannot follow or a name from outside the body
          1 module renders in the browser for it
            routes/page.island0.tsx#default  <SfIsland> routes/page.tsx:8:7
routes/page.tsx(7,21): error TS2304: Cannot find name 'sign'.
typecheck tsc 7.0.2 from cache, 1 error

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:

guestbook/app/routes/page.tsx
import type { RootProps } from "@generated/client";

export default function Home({ csrf_token }: RootProps) {
  return (
    <section>
      <h1>Sign the guestbook</h1>
      <form method="post" action="/_sf/action/$root.sign">
        <input type="hidden" name="_csrf" value={csrf_token ?? ""} />
        <input name="name" placeholder="Your name" />
        <input name="line" placeholder="Say something" />
        <button type="submit">Sign</button>
      </form>
    </section>
  );
}

fsr check guestbook/app now prints nothing and exits 0.

Run it#

$ fsr dev guestbook/app

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.jsWhat to do
a "use server" function used as a form's action or passed aroundpost 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.enva 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, useSearchParamsparams 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.