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.

Imports

Next.js here
next/link @rsc-kit/core/Linkhref is typed to your routes, and search to the page’s schema
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 from @rsc-kit/core/request
redirect() from next/navigation @rsc-kit/core/redirect
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/metadatametadataBase, openGraph, twitter, icons as you had them
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
next.config.js vite.config.ts — Tailwind, aliases and plugins are Vite’s
next-safe-action createActionClient() — same shape, below

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 requestcookies(), 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
     dynamic — called cookies()

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, with field() for a controlled binding and useField() for a value read anywhere. 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 and it is picked up; generate one at build time if it has to be generated. 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.
  3. bun run build and read the output: every route that is not says why. Most surprises are a cookies() in a layout making everything dynamic — the build says so under the summary.
  4. Actions not built from a client are listed. Decide for each.
  5. 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