Nuxt vs Remix: What a Framework Move Actually Rewrites
Vue's Nitro server against React's loader and action model. In a working paid backend, 760 lines are framework-free and 226 are adapter. Counted here.
Nuxt is Vue's meta-framework, with a Nitro server and file-based API routes. Remix is React's loader-and-action framework, now carried by React Router v7. For a paid product the choice changes less than it looks: in our storefront the authorization and signature logic is 760 framework-free lines, and only 226 lines of route adapter would be rewritten on either.
We run Next.js 16 and have never shipped a Nuxt or Remix build, so nothing here is a benchmark of either. What we can measure is the one thing a framework move would actually touch: how much of a working commerce backend is the framework, and how much is not.
The real axes
| Nuxt | Remix / React Router v7 | |
|---|---|---|
| UI library | Vue 3 | React |
| Server layer | Nitro, with server/api/ handlers | loader / action exports, plus resource routes with no UI |
| Reading a request body | readRawBody(event) (h3) | request.text() — a standard Request |
| Reading data for a page | useFetch / useAsyncData | loader, colocated with the route |
| Mutations | A server/api handler you call | action, colocated with the route |
| Where a global hook runs | Route middleware and server/middleware/ | Per-route in loaders; no single file on every request |
| Hiring pool | Vue developers | React developers |
| Closest thing in this repo | None — we do not run it | None — the "use server" actions are the nearest relative |
Names in the second and third columns are the documented primitives; confirm exact signatures against each framework's current docs before you build on them, because both have shifted across majors. For the rendering-model argument see React vs Remix and Next.js vs Nuxt; for the loader/action mapping against a third framework see SvelteKit vs Remix. This post asks a narrower question.
What does a framework move actually rewrite?
Nuxt and Remix both let you write a server endpoint, so the question for anyone selling something is not "which has the nicer routing". It is how much of the paid path is framework code. Counted in this repository:
| File | Lines | What it is |
|---|---|---|
src/lib/download.ts | 373 | resolveDownload — the whole 401 → 403 → 400 → 403 → 429 → 404 → 302 decision |
src/lib/lemonsqueezy/webhook.ts | 287 | processWebhook — verify, parse, record, refund |
src/lib/entitlements.ts | 75 | authorizeDownload and the slot rules |
src/lib/lemonsqueezy/signature.ts | 25 | HMAC verification |
| Decision core | 760 | None of it knows what a route is |
src/app/api/download/route.ts | 157 | Adapter: session, Supabase clients, HTTP response |
src/app/api/webhooks/lemonsqueezy/route.ts | 69 | Adapter: raw body, env, after() |
| Adapters | 226 | The only code a framework move rewrites |
src/lib/download.ts imports nothing from next. Its only non-local import is "server-only", a package marker, and the one place "next" appears in the file is a hostname in a comment. The 81 tests across download.test.ts, download-authz.test.ts, webhook.test.ts, webhookRoute.test.ts, signature.test.ts and entitlements.test.ts run against in-memory fakes, so they would survive a move to Nuxt or Remix untouched.
That split is deliberate, and it is the actual answer to "Nuxt or Remix". Both can host this backend. The risk is the adapter layer silently dropping a check, and that risk shrinks when the checks live somewhere neither framework can reach.
The seam: a narrow deps interface
The core takes everything it needs from the outside through one interface, copied from src/lib/download.ts:
export interface DownloadDeps {
/** `null` = unauthenticated. Never derived from request params (R-5/R-11). */
getSession(): Promise<DownloadSession | null>;
/** The caller's own active slots — the real implementation scopes this
* with an explicit `user_id`/`status` filter (R-5), not request input. */
listActiveSlots(userId: string): Promise<Slot[]>;
// ...recordDownloadWithinLimit, createSignedUrl, recordDelivery, onError
}
The Next adapter in route.ts builds those functions from createClient() and createAdminClient(), reads product and framework from the query string, and calls resolveDownload. Then it translates the result: a 302 becomes NextResponse.redirect, a 429 gains a Retry-After header, everything else becomes JSON with a status. A Nuxt handler would do the same job with getQuery and sendRedirect; a Remix resource route would return a redirect or Response. The shape is identical; only the verbs change.
getSession is the one line each framework answers differently, and it is where it matters most. Nuxt would typically get it from a server utility or a module; Remix would read it from a cookie session in the loader. Neither answer belongs inside resolveDownload, which is why it takes a function and not a request.
The defects that cross a framework boundary
Two bugs this codebase actually hit would have happened on either framework, because both live in the adapter's assumptions.
The signature must be computed over the exact bytes. src/app/api/webhooks/lemonsqueezy/route.ts reads await req.text() and never req.json() first. Its comment explains why: re-serialising a parsed object can differ byte for byte from what LemonSqueezy signed. In Nuxt the raw-body helper is the equivalent; in Remix it is request.text() on the standard request. Reach for the parsed body first in either one and the HMAC fails on the first real delivery.
A length mismatch throws. signature.ts compares two hex digests with timingSafeEqual, which throws if the buffers differ in length. The fix that shipped decodes both sides to bytes and checks length first:
export function verifySignature(
rawBody: string,
signatureHeader: string,
secret: string,
): boolean {
if (!signatureHeader) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(signatureHeader, "hex");
return a.length === b.length && b.length > 0 && timingSafeEqual(a, b);
}
A malformed header resolves to false, a 401 or 403 in the adapter, rather than an unhandled exception that returns a 500 to an attacker probing the endpoint. The function imports node:crypto, so the route pins export const runtime = "nodejs"; if you deploy Nuxt or Remix to an edge runtime, you need an equivalent that works there, and that is a platform decision rather than a framework one.
What does not port: the work after the response
The welcome email is sent inside after(() => sendPurchaseWelcome(email, tier)). The comment above it records why: on Vercel an un-awaited promise has no guaranteed lifetime once the response is sent, so a fire-and-forget email can be dropped silently. after() is backed by the platform's waitUntil. Both Nuxt's Nitro and Remix on your host of choice have their own way to run work after responding, or none at all on some targets. This is the line in the adapter most likely to be translated incorrectly because nothing fails loudly when it is wrong.
Choosing
Pick Nuxt if your team writes Vue, you want server routes and a module ecosystem in one place, and the commerce backend will be a handful of server/api handlers.
Pick Remix / React Router if your team writes React and you want forms and mutations to be ordinary web-standard Request and Response code, which also makes the raw-body rule above the default behaviour of the API you are already using.
Pick on the adapter, not the demo. Whichever you choose, keep authorization, signature checks and rate limits in framework-free functions behind a narrow interface. The write-ups of those functions are gated file downloads and LemonSqueezy checkout; the payment-provider choice is a separate decision, covered in LemonSqueezy vs Stripe for digital products. For another framework we do not run, Nuxt vs Qwik argues the same thing about where a content site's numbers come from.
Troubleshooting a port
| Symptom | Likely cause | Fix |
|---|---|---|
| Every webhook delivery fails verification | Body parsed before the HMAC was computed | Read the raw text first, verify, then parse |
| Verification throws on a junk header | timingSafeEqual on unequal-length buffers | Decode to bytes and compare lengths first |
| Welcome emails vanish in production only | Fire-and-forget promise on a serverless host | Use the platform's after-response hook |
| Downloads work for anyone with a link | Authorization lived in a client-side check | Re-run the decision server-side per request |
| Tests need a live database | Core imports a client directly | Pass the client in through a deps interface |
| 500s on bad input | A thrown error reaches the framework default | Map known failures to explicit statuses in the core |
Frequently asked questions
Is Nuxt or Remix better for a SaaS? Neither is better for the paid path. Choose by team language, then keep the entitlement and webhook logic in plain functions so the framework stays replaceable.
Is Remix still a separate framework? Its loader-and-action model now lives in React Router v7's framework mode, as described in React vs Remix. Check the current docs for naming and migration guidance.
Can the same webhook code run on both? The verification and recording logic can, because it only needs a raw string, a header and a secret. The surrounding handler and the after-response hook are written per framework.
Do I need a separate backend for payments? Not necessarily. This storefront runs its webhook and download endpoints as route handlers beside the pages, with the database behind Supabase. Whether you split that out is a scale and ownership question, not a framework one.
Templates in this post
ASoc Beacon is a mobile-device-management site with a device-console dashboard mock, ASoc Beaker is a research-laboratory site with a services matrix and pricing tiers, and ASoc Blueprint is an app-development agency site with case studies and project pricing. All three are statically prerendered, leaving the commerce backend as the only part you wire up.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
