---
title: "Move a Next.js app with fsr migrate"
audience: "anyone with a Next.js App Router app to move"
description: "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…"
snippet_caption: "shell"
snippet: "$ fsr migrate guestbook-next guestbook\nfrom      Next.js App Router\nwrote     8 files under guestbook\nnext      fsr add guestbook/app clsx@2.1.1\nnext      fsr check guestbook/app"
---

import { Install } from "@src/ui/Install";

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](https://snapfirers.com/fsr/learn/320-port-a-next-js-route) ports a route by hand. This one lets the command do the moving.

<Install captured="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.

```json title="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"
  }
}
```

```tsx title="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>
  );
}
```

```tsx title="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>
  );
}
```

```ts title="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("/");
}
```

```tsx title="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:

```shell-session
$ 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:

```tsx title="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:

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

```ts title="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:

```ts title="guestbook/app/schemas/signinput.ts"
export interface SignInput {
  name: string;
  line: string;
}
```

```ts title="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:

```shell-session
$ 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:

```shell-session
$ 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:

```tsx title="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

```shell-session
$ 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.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](https://snapfirers.com/fsr/learn/375-call-a-graphql-api) |
| `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](https://snapfirers.com/fsr/learn/330-add-a-javascript-library).
