Skip to main content
ASoc
Comparison

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.

The ASoc Team8 min read

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

NuxtRemix / React Router v7
UI libraryVue 3React
Server layerNitro, with server/api/ handlersloader / action exports, plus resource routes with no UI
Reading a request bodyreadRawBody(event) (h3)request.text() — a standard Request
Reading data for a pageuseFetch / useAsyncDataloader, colocated with the route
MutationsA server/api handler you callaction, colocated with the route
Where a global hook runsRoute middleware and server/middleware/Per-route in loaders; no single file on every request
Hiring poolVue developersReact developers
Closest thing in this repoNone — we do not run itNone — 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:

FileLinesWhat it is
src/lib/download.ts373resolveDownload — the whole 401 → 403 → 400 → 403 → 429 → 404 → 302 decision
src/lib/lemonsqueezy/webhook.ts287processWebhook — verify, parse, record, refund
src/lib/entitlements.ts75authorizeDownload and the slot rules
src/lib/lemonsqueezy/signature.ts25HMAC verification
Decision core760None of it knows what a route is
src/app/api/download/route.ts157Adapter: session, Supabase clients, HTTP response
src/app/api/webhooks/lemonsqueezy/route.ts69Adapter: raw body, env, after()
Adapters226The 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

SymptomLikely causeFix
Every webhook delivery fails verificationBody parsed before the HMAC was computedRead the raw text first, verify, then parse
Verification throws on a junk headertimingSafeEqual on unequal-length buffersDecode to bytes and compare lengths first
Welcome emails vanish in production onlyFire-and-forget promise on a serverless hostUse the platform's after-response hook
Downloads work for anyone with a linkAuthorization lived in a client-side checkRe-run the decision server-side per request
Tests need a live databaseCore imports a client directlyPass the client in through a deps interface
500s on bad inputA thrown error reaches the framework defaultMap 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.

Keep reading

Comparison7 min read

One-Time Payment vs Subscription: What the Webhook Handler Has to Know

Our entitlement engine is 75 lines with two status states; our LemonSqueezy webhook recognizes two event names. That's not a shortcut — it's the real cost gap between the two pricing models.

Read more
Comparison8 min read

Playwright vs Vitest: Why This Repo Runs Both for Unrelated Jobs

Vitest is a committed dependency gating 305 tests on every push. Playwright is deliberately not a dependency at all — installed ad hoc for a script that runs a few times a year.

Read more
Comparison9 min read

Qwik vs. Next.js: Resumability vs. Not Shipping the Component

Qwik ships near-zero JS through resumability; this site's RSC split already does that for 9 of 10 home-page sections. Where the real cost still lands: one accordion.

Read more