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 storedawait 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:
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
bun create rsc-kit@latestand copysrc/appover the scaffold’s.- Fix imports from the table.
bun run typecheckfinds the rest — and the build runs the same check first, asnext builddoes, so a link to a route that does not exist cannot ship. bun run buildand read the output: every route that is not○says what streams and from which component. Most surprises are acookies()in a layout reaching every page — the build says so under the summary.- Actions not built from a client are listed. Decide for each.
- Convert the forms and the actions, do not carry them. A Next app’s
useActionState+useFormStatuspair, areact-hook-formor TanStack Form setup, anduseStateper 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 fromcreateActionClient()(input validated,validationErrorsreturned rather than thrown, oneonError). Drop the form library frompackage.jsonwhen the last form is converted; a port that keeps two form systems has ported nothing. 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.