DEV Community

VEILVM
VEILVM

Posted on

Protecting client-side business logic in a Next.js or Nuxt app

A Next.js or Nuxt app can feel like the business logic already lives on the server. You have Server Components, Server Actions, route handlers, and Nuxt server routes. That helps, until you import the wrong module into a client component and it appears in the browser bundle.

The rule is unforgiving. If a function is pulled into a client graph, it ships. The file name, the folder, and the fact that you also use it from a Server Component do not matter. Once "use client" or a Nuxt client plugin imports that module, the production chunk contains the code, and anyone can pretty-print it.

This article is about keeping pricing, entitlements, and proprietary algorithms on the right side of that boundary, and about raising the cost of copying the logic that genuinely has to run in the browser.

The client boundary is a shipping boundary

In the App Router, Server Components run on the server. Their source is not sent as a client module; the browser receives rendered output and serialized props. Client components — files that start with "use client", or that are imported by such a file — are bundled for the browser.

The leak is almost always transitive:

// app/pricing/DiscountBanner.tsx
"use client";

import { bestDiscount } from "@/lib/pricing";

export function DiscountBanner({ cart }: { cart: Cart }) {
  const offer = bestDiscount(cart);
  return <p>Your price today: {offer.total}</p>;
}
Enter fullscreen mode Exit fullscreen mode

bestDiscount may live in a "shared utilities" file, and it may also be imported from a Server Component. As soon as this client component imports it, the discount engine is in the client chunk. A competitor opens DevTools, pretty-prints the file, and has the rules: coupon stacking, volume breaks, the regional override.

Pages Router has the same issue: anything imported from a hydrating page or component is client code. Helpers called only from getServerSideProps or getStaticProps stay on the server if no client module also imports them.

Nuxt is the same shape with different labels. Keep pricing and entitlements in server/api or a server-only utility that is never imported from a .vue file or a client plugin. useFetch hitting your own API is fine. Importing the pricing function into components/DiscountBanner.vue is not.

A better split

Move the authoritative calculation behind a Server Action, a route handler, or a Nuxt server route. The client asks for a result; it does not contain the engine.

// app/pricing/actions.ts
"use server";

import { bestDiscount } from "@/lib/pricing";
import { getSession } from "@/lib/auth";

export async function quoteCart(cart: Cart) {
  const session = await getSession();
  return bestDiscount(cart, session);
}
Enter fullscreen mode Exit fullscreen mode
// app/pricing/DiscountBanner.tsx
"use client";

import { useEffect, useState } from "react";
import { quoteCart } from "./actions";

export function DiscountBanner({ cart }: { cart: Cart }) {
  const [offer, setOffer] = useState<Offer | null>(null);
  useEffect(() => { quoteCart(cart).then(setOffer); }, [cart]);
  if (!offer) return null;
  return <p>Your price today: {offer.total}</p>;
}
Enter fullscreen mode Exit fullscreen mode

The client still displays a price. It no longer ships the formula. The server can apply entitlements the browser should not be trusted to enforce: plan, coupon validity, region. In Nuxt, the same split is server/api/quote.post.ts: the component calls $fetch('/api/quote', { method: 'POST', body: cart }) and renders the response.

Two caveats. A Server Action is only as trustworthy as its server-side checks — if it blindly trusts a plan: "pro" field from the client, you have only moved the typo. And if optimistic UI needs a rough estimate, keep that estimate approximate and never let it authorize anything.

What still has to live in the client

Some logic has a reason to run in the browser: an instant preview, an offline calculator, a canvas tool, a mapping from a server-issued entitlement into UI state. That code will ship. Send as little of it as possible.

A workable pattern: the server issues a signed, short-lived entitlement or quote (not a bag of booleans the client can flip); the client contains only the mapper and the UI; every mutation still goes through the server, which re-checks the entitlement. If a proprietary algorithm must run client-side — an offline solver, a local preview of a paid transform — treat that function as the sensitive surface, not the whole app.

Production bundles are easy to pretty-print

A production Next build lands JavaScript under .next/static/chunks/. Nuxt 3 lands it under _nuxt/. The files are minified. They are not hidden.

find .next/static -name "*.js" | head
find .output/public/_nuxt -name "*.js" | head
Enter fullscreen mode Exit fullscreen mode

In Chrome, open DevTools → Sources, pretty-print the chunk, and search for a string from your pricing copy. You will usually find the logic in minutes. Minification is a size optimization, not protection.

Turn production source maps off, or upload them only to a private crash tracker. A public .map with sourcesContent undoes minification and obfuscation. In Next, keep productionBrowserSourceMaps false. In Nuxt, keep sourcemap.client off in production. Do not host maps next to the chunks.

Environment variables that leak on purpose

NEXT_PUBLIC_* and NUXT_PUBLIC_* are inlined into the client bundle. That is the contract. A "secret" in one of those variables is not a secret.

NEXT_PUBLIC_STRIPE_KEY=pk_live_...    # publishable key: ok
NEXT_PUBLIC_ADMIN_TOKEN=...           # never
NUXT_PUBLIC_PRICING_SALT=...          # never

grep -R "NEXT_PUBLIC_" -n . --include='*.ts' --include='*.tsx'
grep -R "NUXT_PUBLIC_" -n . --include='*.ts' --include='*.vue'
Enter fullscreen mode Exit fullscreen mode

Publishable Stripe keys and analytics IDs belong there. API tokens, signing secrets, webhook secrets, and "hidden" feature-flag keys do not.

Minify, obfuscate, or narrowly VM-protect

For the code that remains on the client:

  • Minify everything. This is default in production builds and should stay on.
  • Apply ordinary obfuscation (renaming, string encoding, light control-flow changes) to your own modules, not to node_modules or presentational UI.
  • Reserve VM-style protection for a short list of functions that would hurt you if they were copied: an offline algorithm, an entitlement mapper, a preview engine you sell.

A JS VM obfuscator compiles selected functions to a custom bytecode and ships a small interpreter, so the original control flow is not sitting in the chunk waiting for a beautifier. VEILVM is a browser-based JSVMP workspace you can point at one extracted function before you wire protection into CI. Interpreted code is slower, so extract the sensitive function, protect that function, and leave the React or Vue render path alone.

Protection is not a way to keep a secret or enforce a price. The server still has to be the authority. Protection raises the cost of casual inspection and wholesale reuse of the client remainder.

A checklist before you ship

  • [ ] Pricing, discounts, and entitlement grants run on the server (Server Action, route handler, or Nuxt server route).
  • [ ] Client components do not import those modules unless a preview is deliberately approximate.
  • [ ] No NEXT_PUBLIC_ / NUXT_PUBLIC_ value is a secret.
  • [ ] Production source maps are not on the CDN.
  • [ ] You can name the few client functions that deserve more than minification.
  • [ ] End-to-end-tests run against the production build, including protected functions.

If you want to try VM protection on a single client function, VEILVM runs in the browser with no sign-up: 3 free runs a day, up to 5 KB per file (resets at 00:00 UTC). A free account raises the input limit to 30 KB. Pro is $9/month or $79/year and adds an API so the protection step can run in CI. Standard requests are processed in memory and are not written to logs. Start at https://vm.ldvmp.com.

Top comments (1)

Collapse
 
mp3sss31 profile image
mp3sss •

The transitive-import point is the one most teams miss: folder naming and 'we also use it on the server' don't protect a module once any use client file reaches for it, so the check has to live at build time. Marking pricing/entitlement modules with a server-only guard (or a lint rule that fails the build when they enter the client graph) catches this before it ships, instead of relying on review memory. The decision that matters is which side recomputes the secret: keep pricing and entitlement verdicts server-side and send only the result to the browser; anything genuinely client-side can be obfuscated to raise the copying cost, but it shouldn't carry the actual security decision.