Skip to content

Add a Hosted Fields example on Next.js and React - #1

Merged
evsinev merged 1 commit into
mainfrom
add-nextjs-example
Sep 11, 2026
Merged

Add a Hosted Fields example on Next.js and React#1
evsinev merged 1 commit into
mainfrom
add-nextjs-example

Conversation

@evsinev

@evsinev evsinev commented Sep 11, 2026

Copy link
Copy Markdown

Adds nextjs/, a third merchant integration of the same payment, next to go-js/ and
nodejs-express-js/. Next.js 15 (App Router) + React 19 + TypeScript, ported from go-js/
one to one: same OAuth 1.0a RSA-SHA256 signing, same three gateway calls, same wire contract
on /pay and /status, same control check on the 3DS return, same screens.

The first two examples answer "which server language". This one answers a different question —
what the integration looks like when the page is React — and that is the only reason to have
it. The card fields are cross-origin iframes the SDK injects imperatively into elements React
also owns, which is exactly the case React handles badly by default.

What is shared and what is not

public/styles.css is now shared by all three apps, byte for byte, and CI diffs it against
all three. The React example cannot share checkout.js, status.js or the two views — they
are ported to components — so those four stay a two-app contract as before. That split is what
keeps the three looking identical without pretending React can consume a static HTML file.

The screens match docs/checkout.png and docs/result.png to the pixel.

The two places Next forced a different shape

Both are documented in nextjs/README.md and nextjs/CLAUDE.md, and neither weakens what
go-js was protecting.

The 3DS return. An App Router page cannot serve a POST, and page.tsx and route.ts
cannot share a path. So redirect_url points at {prefix}/result/callback, which recomputes
sha1(status + orderid + merchant_order + MERCHANT_CONTROL), answers 403 on a mismatch, and
otherwise puts the verified order into a short-lived httpOnly cookie and answers 303 to
{prefix}/result. The identifiers the page polls with still come from the gateway's signed
callback, never from the browser's URL.

BASE_PATH is build-time. It becomes basePath, which next build bakes into the output.
Keeping it in .env means the build and the runtime agree, but changing it means rebuilding.
PORT is the other odd one: Next picks the port before it loads .env, so it only works as a
real environment variable — yarn dev / yarn start default to 3002 and systemd supplies it
through EnvironmentFile. Everything else .env drives at runtime, standalone included.

No NEXT_PUBLIC_*: the SDK URL, endpoint id, ephemeral ticket and amount reach the browser as
props from the server component, which is the React shape of the window.CONFIG the other two
inject. That keeps one runtime-configured artefact instead of baking credentials into a bundle.

React and the SDK

Four rules, each enforced and explained at its site, because each one silently breaks the card
fields if ignored:

  • the three containers render no children and their className is a constant — React
    leaves foreign DOM alone but rewrites an attribute whose rendered value changed, which would
    wipe the hf-field--focus / --filled classes the SDK sets. The error ring therefore goes
    through classList, not through a rendered class;
  • HostedFields.init() runs once behind a ref guard, because React 19 invokes effects
    twice in development StrictMode — and nothing is destroyed on cleanup for the same reason;
  • sdk.setStyle() is pushed for all three fields on every theme change, after data-theme
    is on <html>, since the iframes inherit nothing from the stylesheet. field-style.ts reads
    the values back out of the same --pay-* custom properties the container uses, so the two
    sides of the seam cannot drift;
  • settings are validated lazily, not at module import: next build imports the route
    modules to collect them, and CI builds without credentials.

Also in this PR

  • CI: new nextjs job (yarn install --frozen-lockfile, yarn lint, yarn build); the
    shared frontend job now diffs styles.css across three apps and the scripts and views
    across two.
  • Release: hosted-fields-examples-nextjs.tar.gz from output: 'standalone'server.js,
    .next/, public/ and deploy/, runs on plain Node 20 with no npm install.
  • deploy/: systemd unit, nginx snippet and env template, port 3002, prefix
    /hosted-fields-examples-nextjs.
  • Root README.md and CLAUDE.md updated for the third example; nextjs/README.md and
    nextjs/CLAUDE.md written.

Verification

Run against the sandbox, not just built:

  • full payment with 4444 4444 4444 4448 — tokenize, Sale, status polling, 3DS, approved,
    with Cardholder ANNA WEBER on the result panel, which is the merchant's own input between
    the gateway's iframes reaching the Sale as card_printed_name;
  • theme toggle carries into the iframes (sdk.setStyle), cardholder auto-fill and
    upper-casing with caret preservation, per-field validation firing before the ticket is spent;
  • tampered control on the callback answers 403; a bare GET {prefix}/result shows the
    "nothing to show" state; the standalone artefact runs and serves styles.css under the prefix;
  • no console errors and no hydration warnings; yarn lint and yarn build clean.

One gap: the local .env has no real MERCHANT_CONTROL, so the genuine cross-site POST from
the gateway was answered 403 (signature computed against a placeholder). The callback route,
the signature check, the cookie, the 303 and the polling on /result were all verified with
a same-site POST carrying a correctly computed control. What remains unproven end to end is
only the browser accepting Set-Cookie on a cross-site POST — standard behaviour, since
SameSite governs sending and the 303 makes the follow-up a top-level GET, which Lax allows.
Worth one run with the real secret before tagging a release.

@evsinev
evsinev merged commit f7d4429 into main Sep 11, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant