Skip to content

Coming from Next.js

What carries over unchanged, what to rename, and what is different on purpose.

Updated View as Markdown

Most of a Next.js app directory moves over as it is. This page is the rest, in the order a port meets it.

What is the same

The app/ conventions are the same conventions: layout.tsx, page.tsx, loading.tsx, error.tsx, not-found.tsx, route.ts, [slug], [...path], (group), @slot and (.)intercept. "use client" and "use server" mean what they mean in React. cookies() and headers() are async and have the same names. redirect(), notFound(), generateStaticParams() and export const metadata all exist. Streaming through <Suspense> works the same way, because it is React doing it.

Copy src/app across first and fix imports second. Most files need only the imports.

A component library works the way it did: a server component can import @base-ui/react or Radix directly, and the parts marked "use client" inside it become client references. A shadcn-style components/ui/ folder still wants "use client" at the top of each file, as those files ship it — without it, the server evaluates the library’s internals for nothing on every render.

The build names the case that breaks: a server file whose code, or a library’s under it, reaches a hook the server does not have.

ℹ  1 server file reaches client-only React: components/ui/sheet.tsx (useState; imported by app/layout.tsx)

A render that calls one fails with (0, import_react_react_server.useState) is not a function, which names React rather than the file. Add "use client" to the file it names. Icons, and libraries that ship their own "use client", are not listed. A library that keeps a hook in an export the page never renders can be, and that one is fine.

Imports

Next.js here
next/link @rsc-kit/core/Link — href is typed to your routes, and search to the page’s schema
import type { Route } from 'next' import type { Route } from '@rsc-kit/core/routes' — pages and route.ts files alike, as in Next; a link to a route is an anchor, never prefetched
useRouter().push(url) visit(url) from @rsc-kit/core/router; replace: true for .replace()
useRouter().refresh() refresh() from @rsc-kit/core/router — or better, revalidate() from the action
usePathname() @rsc-kit/core/usePathname
useSearchParams() @rsc-kit/core/useSearchParams, or nuqs with our adapter
useParams() the page’s params prop, passed down
cookies(), headers() from next/headers the same names and shapes from @rsc-kit/core/request — cookies().get(name) is { name, value }, getAll() a list, set() takes either call form
redirect() from next/navigation @rsc-kit/core/redirect
after() from next/server after() from @rsc-kit/core/request — handed to waitUntil on a Worker, detached on a process; server actions
notFound() @rsc-kit/core/not-found
revalidatePath(), revalidateTag() revalidate('tag') from @rsc-kit/core/revalidate — see below, it is narrower
Metadata from next @rsc-kit/core/metadata — metadataBase, openGraph, twitter, icons as you had them
Viewport from next, export const viewport the same export, typed from @rsc-kit/core/metadata; the charset and viewport tags Next wrote for you are written here too — Metadata
cookies().set() in an action, then revalidatePath() the same: a cookie set is what cookies().get() answers for the rest of the request, the revalidated sections included
next/font Fontsource: install the font, import its CSS
next/image unpic for a CDN, vite-imagetools for files in the repo
next/script a <script> tag: React 19 hoists and dedupes async scripts itself
NEXT_PUBLIC_* VITE_*, read through import.meta.env; everything else stays process.env on the server
flags/next (Vercel’s Flags SDK) unchanged — the build answers its next/headers import with headers() and cookies() from here; Feature flags
next.config.js vite.config.ts — Tailwind, aliases and plugins are Vite’s
serverExternalPackages rscKit({ serverExternalPackages }), with the same default list — sharp, bcrypt, prisma and the rest stay outside the bundle; Running on Bun
instrumentation.ts with register() the same file, in src/ — imported before any page and awaited before the first request; startup
next-safe-action createActionClient() — same shape, below
experimental.optimizePackageImports on by default for every barrel package the server imports, with no list — read from the barrel itself; rscKit({ barrelImports: false }) turns it off
a middleware.ts rewrite for subdomains nothing — a host is a route segment, so acme.example.com/ reaches app/[domain]/page.tsx and admin.example.com/ reaches app/admin/page.tsx — domains
a hand-written OpenAPI spec, @scalar/nextjs-api-reference rscKit({ openapi }) derives the document from every route.ts and its schemas; the page is Scalar’s renderer, @scalar/client-side-rendering, in a route.ts — the Next package works too but installs Next with it — OpenAPI
app/robots.ts, app/sitemap.ts the same files, the same shapes — robots, sitemap and llms.txt; app/llms.ts beside them
@react-email/render in a server action the same call, in a module that starts with "use ssr" — emails. Next fails the same way where it renders server components; the directive is how this one moves it
cache from react cache from @rsc-kit/core/cache — React’s memoises only inside a component render; this one spans the request, so a guard, the layout and the action share one call. The build names server files still importing React’s

Different on purpose

Nothing is dynamic by declaration

There is no export const dynamic = 'force-dynamic' and no revalidate = 60. A page is frozen at build time unless it reads the request — cookies(), headers(), searchParams, await connection() — and then it renders per request, or as a shell with the reading part streamed in. The build prints which, and why:

  ○  /about               no js
  ◐  /orders              85 kB
     cookies() in RootLayout streams per request; the rest is stored

await connection() is the one explicit mark, for a page that must render per visitor and does not happen to read anything. Time-based ISR does not exist: a frozen page changes when you build, a dynamic one on every request, and edge caching covers the middle.

Middleware is per directory, not one file at the edge

Next has one middleware.ts that runs on a matcher, on the edge runtime, with a restricted API. Here a middleware.ts sits in the directory it protects, runs on the server with the full API, and covers everything below it:

src/app/admin/middleware.tsts
export default async function middleware() {
  if (!(await currentUser())?.isAdmin) redirect('/login');
}

It does not run for actions — an action renders no route — which is why the check for an action belongs in the action. See Authorization.

Actions

next-safe-action users will find the same shape under a different name. createActionClient() chains middleware, validates with any Standard Schema, and returns failures as { validationErrors } or { serverError } rather than throwing them across the wire:

export const client = createActionClient().use(async ({ next }) => {
  const user = await currentUser();
  if (!user) throw new ServerAuthenticationError();
  return next({ ctx: { user } });
});

export const createPost = client.input(schema).handler(async ({ input, ctx, fieldErrors }) => {
  if (await slugTaken(input.slug)) return fieldErrors({ slug: 'Already taken' });
  return save(input, ctx.user);
});

returnValidationErrors(schema, { email: { _errors: [...] } }) becomes return fieldErrors({ email: 'Account not found' }). useAction from next-safe-action is useActionState, or a <Form action={createPost}> that reads the returned errors on its own. The build lists any action not built from a client, because nothing checks who calls those.

Revalidation is targeted

revalidatePath('/orders') re-renders the page. revalidate('orders') re-renders the section registered under that name and sends it back with the action’s own response — one request, the rest of the page untouched, a half-typed input elsewhere on it still typed. Wrap the region in section('orders', Orders) and name it from the action.

Forms

<Form> from @rsc-kit/core/form submits to an action, shows pending state, places field errors, and works before hydration. It is uncontrolled by default like React Hook Form’s register, and read with two words: error('title') for a field’s error, field('title') for the rare field whose value you hold — the same two in a child component through useForm(). shadcn’s Field components fit as they are. See Forms.

Query strings are typed

Export a schema beside the page and the values arrive parsed; the same schema types every <Link search={…}> to it. Number(searchParams.get('page')) is not a thing you write here. See URL validation.

There is no image optimizer, and no opengraph-image.tsx

Both are processes Next runs for you at request time. Put opengraph-image.png in src/app, or in the folder of the page it is for, and it is picked up; generate one at build time if it has to be generated. See a page’s own share image. Images: unpic or imagetools.

Testing does not need a browser

createTestApp() hands back the deployed Request → Response handler. Actions, queries and api routes are plain functions. There is no equivalent in Next; see Testing.

The porting order that worked

  1. bun create rsc-kit@latest and copy src/app over the scaffold’s.
  2. Fix imports from the table. bun run typecheck finds the rest — and the build runs the same check first, as next build does, so a link to a route that does not exist cannot ship.
  3. bun run build and read the output: every route that is not ○ says what streams and from which component. Most surprises are a cookies() in a layout reaching every page — the build says so under the summary.
  4. Actions not built from a client are listed. Decide for each.
  5. Convert the forms and the actions, do not carry them. A Next app’s useActionState + useFormStatus pair, a react-hook-form or TanStack Form setup, and useState per input all still compile here — which is exactly why an agent leaves them. Each one becomes <Form action={…} schema={…}> (pending state, field errors, works before hydration, uncontrolled by default) and a handler from createActionClient() (input validated, validationErrors returned rather than thrown, one onError). Drop the form library from package.json when the last form is converted; a port that keeps two form systems has ported nothing.
  6. bun run check. Then a browser, for the parts that are a browser’s.

An agent doing the port has all of this: the .mcp.json in the scaffold answers how_to({ topic }) and read_guide({ slug }) from the installed version, and the build report is what it reads instead of guessing.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close