@rsc-kit/mcp
Version:
An MCP server over what an rsc-kit build decided: the routes, why each one is static or not, and what it costs the browser.
185 lines (149 loc) • 11.5 kB
Markdown
# Coming from Next.js
> What carries over unchanged, what to rename, and what is different on purpose.
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.
## 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](/guides/routing#search-params-as-state) 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](/guides/server-actions#work-the-visitor-should-not-wait-for) |
| `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](/guides/metadata#charset-and-viewport) |
| `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](/guides/fonts): install the font, import its CSS |
| `next/image` | [unpic](/guides/images) for a CDN, `vite-imagetools` for files in the repo |
| `next/script` | [a `<script>` tag](/guides/third-party-scripts): 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](/guides/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](/hosts/bun#native-dependencies-stay-outside-the-bundle) |
| `instrumentation.ts` with `register()` | the same file, in `src/` — imported before any page and awaited before the first request; [startup](/guides/instrumentation) |
| `next-safe-action` | `createActionClient()` — same shape, [below](#actions) |
| `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](/guides/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](/guides/openapi) |
| `app/robots.ts`, `app/sitemap.ts` | the same files, the same shapes — [robots, sitemap and llms.txt](/guides/seo-files); `app/llms.ts` beside them |
| `@react-email/render` in a server action | the same call, in a module that starts with `"use ssr"` — [emails](/guides/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](/guides/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:
```ts title="src/app/admin/middleware.ts"
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](/guides/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:
```ts
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](/guides/sections) 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](/guides/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](/guides/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](/guides/metadata/#a-pages-own-share-image). Images: [unpic or imagetools](/guides/images).
### 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](/guides/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.