Putting a Rust server behind somebody else’s Next.js storefront
Porting Next.js Commerce onto a Rust platform with no server JavaScript: generated contracts, runtime adapters, and what the frontend assumed about its server.

In August 2026 I ported Next.js Commerce, Vercel’s reference storefront, onto Ferrite, my Rust application platform. It was a Shopify shop with a homepage merchandised from two hidden collections, URL-driven variant selection, full-text search and a cart whose optimistic reducer settles against server truth, and the rule of the port was that Vercel’s component tree stayed Vercel’s. The only files rewritten were the server-only and 'use server' query and action modules. Everything else was copied verbatim, with a parity check holding the copy to upstream. The Rust supplied what used to sit behind those modules: each export of upstream’s Shopify library as a loader with the same name and cache tags, a cart engine in place of Shopify’s cart API, and the five server actions, down to their exact error strings.
Replacing everything under a frontend while leaving its components alone is a good way to find out what that frontend believes about its server. It also requires some way of keeping the two halves in agreement, and Ferrite rules out the usual ones.
The contract between the halves
A Ferrite application gets one Cargo dependency, no build script, no procedural macros and no ambient authority, and an admission gate scans its source to hold it to that. Nothing on the server runs JavaScript. That leaves no derive macro to reflect on handler types, no build.rs to emit a client, no second crate to hold a framework and no Node process to run Next. The framework I built for this environment deals with those one at a time. It ships as a source package, which a composition tool writes into the application’s src/ tree with rewritten namespaces, provenance headers and a SHA-256 lockfile, so that it runs under the application’s own authority and gets scanned like the application’s own code. The tool’s drift and orphan checks mean a hand-edited copy can’t hide, and the same mechanism carries shared pieces such as the query decoder, so that routes don’t each hold their own opinion about percent-encoding and duplicate parameters. An application is then one repository producing two artifacts: a constrained Rust crate that owns routing, data loading, mutations, caching and document assembly, and an ordinary bundle built with Vite on React 19 and TanStack Query that owns the interface.
Agreement between those two comes from a generator that runs on the developer’s machine, where it is ordinary host tooling and the gate doesn’t apply. It reads the application’s declarations and writes a Rust route registry along with TypeScript types, a client, hooks and a route table. All of that output is committed and readable, and rustc and the TypeScript compiler both check it. In the storefront, add_item is declared in Rust with its input, AddItem { selected_variant_id: Option<String> }, and the cache tags it may advance, cart-*. From that the generator writes the server’s action table entry and a TypeScript addItem that posts to the framework’s action route with an idempotency-key header, a fresh UUID unless the caller provides one. The header of the generated code states the consequence plainly: rename a Rust field and the TypeScript build breaks on the next dev cycle.
The type mapping is unaccommodating by design. Integers too wide for JavaScript’s 53 bits cross the wire as decimal strings, and a type the generator doesn’t recognize is a build error where a friendlier tool would emit any. A float anywhere in an action’s result is a build error too, for a reason that has little to do with precision. The result is recorded for replay when the action commits and a NaN serializes to null, so by the time anybody sees the bad value the commit has already happened. The storefront never trips over this rule, because upstream’s Money.amount was a string to begin with.
The rewritten cart module makes the boundary visible: it supplies the generated input and result types to action(), but passes "add_item" as an ordinary string. TypeScript checks the input fields; renaming the Rust action won’t make that stale string a compile error. Those types also don’t validate an incoming HTTP body, so the handler still parses it and answers 400 when it can’t, treats a blank variant id as absent, and refuses any cache tag the action didn’t declare.
What the frontend assumed
Upstream’s add-to-cart button calls useActionState(addItem, null) and binds the variant with formAction.bind(null, selectedVariantId), which assumes a server action is a function you can hand to a form. A small rewritten module keeps the face upstream’s reducer expects, and underneath it an adapter’s action() takes over the round trip: it sends the idempotency key and same-origin credentials, reads from the reply the cache tag epochs the server advanced, and invalidates the queries they cover. The port never needs upstream’s updateTag call, because the reply already says what changed.
Upstream’s pages also assume that async components run somewhere. React renders async function components only on the server, and this server has no JavaScript. The adapter’s serverComponent() turns each one into a client-side use() of a promise memoized by its props, tags promises passed as props with their values, and clears the cache on invalidation, and the bundle’s entry point is the one place that calls it, for the pages and the root layout. The components stay untouched, and the price is real. There is no runtime React server rendering. At request time the server substitutes bytes into a cached shell, escaping < as \u003c to keep the data from closing its <script> tag. A hybrid route’s body is therefore a build-time skeleton until hydration, and a route that needs full HTML up front has to be static.
The rest of Next’s runtime got what each import actually needed. Link, navigation, cache and headers have adapters that do real work, and getting refs and image properties through the adapters intact took repairs of its own. @vercel/analytics/next and @vercel/speed-insights/next are no-ops, next/og and next/server exist to satisfy the type checker, and cookies() reads document.cookie.
That last one can see the cart cookie only because upstream’s isn’t HttpOnly. The port reproduces it exactly, Path=/, SameSite=Lax, no HttpOnly, no Max-Age, with a test to keep it that way. A cookie only identifies a cart if it arrives, though, and behind the platform’s managed sign-in the gateway doesn’t forward application cookies, so in that configuration the handler derives the cart id from the authenticated subject instead.
Replacing Shopify’s cart API also means owning what happens when an add arrives twice, and here it will, because the gateway hedges requests and optimistic clients retry. The handler checks for a recorded result first, looks up the variant, creates or increments the cart, and calls commit, which records the result and advances the cart’s tag atomically, and the reply carries the cookie. A new cart’s id comes from a digest of the idempotency key with no ambient randomness, so two deliveries of one add name the same cart. The end-to-end test is the clearest description of the behavior: an add with no cookie creates a cart and sets the cookie, a hedged duplicate leaves the quantity at one, and a new delivery takes it to two with a total of “30.00”.
Browser form posts exposed one more bad assumption, and this one was mine. They pass an origin gate before they are admitted. Origin: null fails, the allowlist compares the whole host[:port] with no default-port folding, and null, empty, suffix-host and host-in-path spoofs are all tested. The gate’s first version read the Host header, which the gateway never forwards, and so refused every browser form POST; the repair reads the gateway’s verified host first. A request with no Origin at all passes, so the gate can only judge what the gateway hands it. The application’s route declaration has to ask for origin, along with cookie, accept and idempotency-key, and staging had a gap in exactly that forwarding.
The storefront was one of several ports, and each pushed on something different. Vercel Labs’ next-beats, a Spotify-shaped music player whose Web Audio engine survives navigation, needed private cache tags scoped per user and the session and demo-toolbar cookies its components read. A social feed needed replay-safe mutations, replies carrying the tag epochs they advanced, and bucketing for clock-derived fields, because “3 minutes ago” goes stale with no write to invalidate it. A project board brought optimistic concurrency, which no earlier port had covered. A document app brought a draft that outlives the request that created it, and a revision fence on publishing, since two authors editing one document is normal collaboration while two authors publishing against the same revision is a lost edit. All of these keep their upstream frontend designs.
I wouldn’t call any of this Next compatibility. It is a Rust backend and a set of adapters shaped by what particular frontends turned out to need, and for anyone planning a similar port the list of no-ops is as informative as the list of things that work.
Chris Chabot, November 2026