UNPKG

@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.

227 lines (185 loc) • 11.5 kB
# Coming from Inertia > A Laravel + Inertia React app, ported page by page — what stays in Laravel, what the pages become, and what goes. An Inertia app already has the split this framework wants: React pages in `resources/js`, and a Laravel application that owns the data, the session and the rules. What changes is **who renders the page**. Inertia renders it in the browser from props a controller assembled; here the page is a server component rendered in front of Laravel, and it asks Laravel for what it needs by name — a [BAP](/hosts/backend-answered-pages). Laravel keeps everything it was good at and stops building pages. This page is the port, in the order it meets things. The [Laravel host page](/hosts/laravel) is the reference for each piece. ## What is the same - **Laravel, all of it.** Models, policies, form requests, middleware, jobs, mail, the session, `auth()` — untouched. Every call a page makes runs as the visitor, with their cookie, so `auth()->user()` is who it was. - **`resources/js`.** The route tree is `resources/js/app`; your components stay where they are and are imported the same way. - **The routes Laravel keeps.** `/login` if Fortify owns it, a webhook, an OAuth callback, a Blade page, `/storage` — any url the React tree does not have is forwarded to Laravel as it was. - **React.** Your components are your components. The interactive ones get `"use client"` at the top; the rest need nothing. ## Install ```sh composer require rsc-kit/laravel php artisan rsc:install ``` `rsc:install` publishes the config, generates the secret, and runs `rsc-kit init`, which writes one `vite.config.ts` (the one `laravel-vite-plugin` owned is moved aside — the renderer owns the frontend now), a root layout and a page under `resources/js/app`, and `dev`, `build` and `start` scripts. `@inertiajs/react`, `laravel-vite-plugin` and Ziggy come out of `package.json` when the last page that imports them is gone. ## What each Inertia piece becomes | Inertia | here | | --- | --- | | `resources/js/Pages/Orders/Index.jsx` | `resources/js/app/orders/page.tsx` — the file is the route; no `Route::get` for it | | `Route::get('/orders', [OrdersController::class, 'index'])` + `Inertia::render('Orders/Index', ['orders' => …])` | nothing. The controller's body moves to `app/Rsc/Orders.php::recent()`, and the page calls `rpc('Orders.recent')` — [Calling PHP](/hosts/laravel#calling-php) | | props from the controller | `await rpc<T>('Class.method', …args)` inside the server component — as many reads as the page wants; siblings in one render are one batched request | | `usePage().props.auth.user` and the rest of `HandleInertiaRequests::share()` | a read in the layout that needs it — `rpc('Shared.auth')` — passed down as props; there is no global props bag | | `usePage().props.flash` | the action's own return value: it comes back to the form that submitted, so nothing has to survive a redirect | | `<Link href={route('orders.show', id)}>` (Ziggy) | `<Link href="/orders/1">` from `@rsc-kit/core/Link` — `href` is typed to the route tree, so Ziggy and `@routes` go | | `router.visit()`, `router.get()` | `visit(url)` from `@rsc-kit/core/router` | | `router.reload({ only: ['orders'] })` | `revalidate('orders')` from the action re-renders the [section](/guides/sections) and sends it back with the answer — one request, the rest of the page untouched | | `router.post()`, `useForm().post('/orders')` | a server action: `php artisan make:rsc-action Orders --method=create`, and `<Form action={ordersCreate}>` — [below](#forms) | | `useForm().errors`, `.processing`, `.recentlySuccessful` | `<Form>`'s `error('field')`, `pending` and `recentlySucceeded` — a `FormRequest`'s `ValidationException` lands on the fields | | `->middleware(['auth', 'verified'])` on the route | `export const middleware = ['auth', 'verified']` in a `middleware.ts` beside or above the page — Laravel's names, run through Laravel's pipeline, before anything below it renders | | `Inertia::defer(fn () => …)`, `<WhenVisible>`, `Inertia::lazy()` | `<Suspense>` around the component that awaits the slow read; the shell streams first — [Partial prerendering](/guides/ppr) | | `<Head title="Orders">` | `export const metadata = { title: 'Orders' }` — [Metadata](/guides/metadata) | | `Page.layout = (page) => <AppLayout>{page}</AppLayout>` (persistent layouts) | `layout.tsx` in the directory — persistent by construction, and rendered on the server | | `app.blade.php` with `@inertia` and `@vite` | `resources/js/app/layout.tsx` with `<html>` and `<body>`; no Blade root view | | `createInertiaApp()` in `app.jsx` | nothing; `init` writes the entry | | `usePoll(5000)` | `usePolling` from [Queries](/guides/queries) | | `<Link prefetch>` | prefetching on hover is the default | | `preserveScroll`, `preserveState` | `<Link preserveScroll>`; state is preserved by the layouts staying mounted — a navigation replaces the page, not the document | | `Inertia::render('Error', …)` from the exception handler | `error.tsx` and `not-found.tsx` beside the pages — [Errors](/guides/errors) | | `RedirectResponse` from a controller | `redirect()` from `@rsc-kit/core/redirect` in the page or the guard; an `RscRedirectException` from PHP is performed by the browser | | `inertia:start-ssr`, `ssr.jsx` | nothing; every page is rendered on the server, and the ones that read nothing per visitor are frozen at build | | `NProgress` on `router.on('start')` | [View transitions](/guides/view-transitions), or nothing — the layouts do not unmount, so there is less to hide | ## Pages An Inertia page receives props and renders. Here it renders **and reads**: ```tsx title="resources/js/Pages/Orders/Index.jsx" export default function Index({ orders }) { return <OrderList orders={orders} /> } ``` ```tsx title="resources/js/app/orders/page.tsx" export default async function Orders() { const orders = await rpc<Order[]>('Orders.recent', 20) return <OrderList orders={orders} /> } ``` `OrderList` is the component you had. If it holds state or handlers it starts with `"use client"` and receives `orders` as props, exactly as before; if it only displays, it needs nothing and ships no JavaScript. The controller method's body is the `Orders::recent()` on the PHP side — resolved through the container, so constructor injection works, and running as the visitor. A `#[Authenticated]` or `#[Can]` attribute on it is a refusal that reaches the page as itself, never as a broken render. ## Shared data `HandleInertiaRequests::share()` put `auth.user` on every page. Here the layout that shows the user reads it: ```tsx title="resources/js/app/layout.tsx" export default async function RootLayout({ children }) { const user = await rpc<User | null>('Shared.auth') return ( <html><body> <Nav user={user} /> {children} </body></html> ) } ``` One read per render of the layout, not per page — and a layout that reads the session makes every page under it render per request, which the build says under its table. Put the read in the deepest layout that needs it. ## Forms `useForm` is the piece that changes most, and the one an agent leaves in place because it still compiles. Convert it; do not carry it. ```sh php artisan make:rsc-action Orders --method=create --auth --revalidate=orders ``` ```php title="app/Rsc/Actions/Orders.php" class Orders { #[Authenticated] public function create(StoreOrder $request): Order { $order = $request->user()->orders()->create($request->validated()); Rsc::revalidate('orders'); return $order; } } ``` ```tsx title="resources/js/app/orders/NewOrder.tsx" 'use client' import { Form } from '@rsc-kit/core/form' import { ordersCreate } from '../../server-actions.generated' export function NewOrder() { return ( <Form action={ordersCreate}> {({ pending, error }) => ( <> <input name="number" /> {error('number') && <p>{error('number')}</p>} <button disabled={pending}>Create</button> </> )} </Form> ) } ``` The `StoreOrder` form request is the one you had. Its `ValidationException` arrives as `error('number')` on the field; `processing` is `pending`; the return value is the answer, and the `orders` section re-renders with it. The form works before hydration and posts natively without it. See [Forms](/guides/forms). Authentication pages port the same way: `Auth.login` with a `LoginRequest` that calls `Auth::attempt()` logs the visitor in — a cookie queued during the call lands on the response — and `redirect('/dashboard')` from the page's guard sends a signed-in visitor on. ## Guards Inertia apps guard in `routes/web.php`. The routes are files now, so the guard is a file beside them: ```ts title="resources/js/app/admin/middleware.ts" export const middleware = ['auth', 'verified', 'can:manage-orders'] ``` Laravel's names, Laravel's pipeline, the real request. A frozen page is asked too, before its file is served. A refusal that redirects — `auth` sending a guest to `/login` — is performed by the browser. See [Authorization](/guides/authorization). ## Different on purpose - **No controller per page.** A page that reads three things calls three methods; there is no method whose job is to assemble one page's props. What was in `OrdersController` is in `App\Rsc\Orders`, callable from any page. - **No props bag, no partial reloads.** A page reads what it reads; a section re-renders when an action says it is stale. `only` and `except` have no equivalent because nothing is shared to be excluded. - **`/` is the React tree's**, welcome route or not. Any url the tree has, React renders; the rest is Laravel's. - **Two processes.** Vite is the renderer in development and Laravel proxies to it through `public/rsc-hot`; in production the built server sits in front. `php artisan serve` needs `PHP_CLI_SERVER_WORKERS` — [why](/hosts/laravel#development). - **The build says what each page costs.** `○` is frozen and served as a file; `◐` streams the part that reads the session. An Inertia app rendered every page per request; most of yours will not. ## The porting order that worked 1. `composer require rsc-kit/laravel && php artisan rsc:install`. Keep the Inertia app running beside it; the route tree takes each url as a page appears in it. 2. Move `Pages/` into `app/`, one directory per url. Persistent layouts become `layout.tsx`. 3. For each page, move the controller method's body into a class under `app/Rsc/` and call it with `rpc()`. Delete the route and the controller method. Shared props become a read in the layout that shows them. 4. `npm run build` and **read the output**: a page that is not `○` says what it read and where. A `Shared.auth` in the root layout reaches every page; move it down. 5. **Convert the forms.** Each `useForm` becomes a `make:rsc-action` and a `<Form>`; each `router.post` a server action. Drop `@inertiajs/react` when the last one is gone. 6. Replace `route()` calls with typed hrefs; `tsc` finds every link to a page that does not exist. Drop Ziggy. 7. `npm run build` again, then the browser. An agent doing the port has all of this: the `.mcp.json` `init` wrote answers `how_to({ topic: 'from-inertia' })` and `read_guide({ slug: 'coming-from-inertia' })` from the installed version.