@r3pos/ecom-next
v0.1.0
Published
Next.js adapter for the R3 Ecom Platform. Mount one App Router route handler and get shopper accounts, guest checkout and order history on the merchant's own storefront — sessions in httpOnly cookies, tokens never readable by browser JavaScript. Built on
Maintainers
Readme
@r3pos/ecom-next
The Next.js adapter for the R3 Ecom Platform.
You run your own storefront on Next.js. You install this, mount one route handler, and you have shopper accounts, guest checkout and order history. The adapter is your backend.
npm install @r3pos/ecom-nextRequires Next.js 15+ (App Router) and React 18.2+, both peer dependencies.
Three files
1. Build the adapter once, server-side.
// lib/r3.ts
import { createR3Ecom } from '@r3pos/ecom-next';
export const r3 = createR3Ecom({
apiKey: process.env.R3_SECRET_KEY!, // r3_sk_live_… — NEVER a NEXT_PUBLIC_ variable
baseUrl: 'https://api.r3pos.com',
tenantId: process.env.R3_TENANT_ID!,
});2. Mount the route handler.
// app/api/r3/[...r3]/route.ts
import { r3 } from '@/lib/r3';
export const { GET, POST } = r3.handlers();3. Mount the middleware. Not optional in practice — see Why the middleware matters.
// middleware.ts
import { r3 } from '@/lib/r3';
export const middleware = r3.middleware;
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Then read the session anywhere on the server:
// app/layout.tsx
import { r3 } from '@/lib/r3';
import { R3Provider } from '@r3pos/ecom-next/react';
export default async function Layout({ children }: { children: React.ReactNode }) {
const session = await r3.getShopperSession();
return (
<html>
<body>
<R3Provider session={session}>{children}</R3Provider>
</body>
</html>
);
}and act on it in any client component:
'use client';
import { useShopper } from '@r3pos/ecom-next/react';
export function AccountBadge() {
const { session, loading, signIn, signOut } = useShopper();
if (session.status === 'guest') {
return <SignInForm onSubmit={(email, password) => signIn({ email, password })} />;
}
return (
<button onClick={() => signOut()} disabled={loading}>
{session.shopper.displayName}
</button>
);
}What this actually guarantees
The shopper is the platform's shopper
Authentication runs against the platform's existing /consumer/auth/*
endpoints. The person who collects stamps at your counter and the person who
orders from your website are one account, one email, one loyalty balance, one
set of saved cards. This adapter does not invent a storefront-only identity,
because a second identity would fork all of that away from their in-store
record.
No token is ever readable by browser JavaScript
The access token and the rotating refresh token live in cookies this adapter owns:
| | value |
| --- | --- |
| HttpOnly | always, on all four cookies — including the profile and guest cookies, so an XSS bug cannot read a shopper's email either |
| SameSite | Lax |
| Secure | on in production, off otherwise (so http://localhost works) |
| Path | /, or whatever you set as cookiePath |
There is no option to weaken any of that. Nothing in this package writes a token
to localStorage, to a script-readable cookie, or into a response body —
GET /session and the object handed to React carry profile data only. That is
asserted by a named test, on the real Set-Cookie header and the real response
body.
Cross-origin state changes are refused
Every mutating route checks the request's Origin (falling back to Referer)
against your storefront's own origin plus anything you list in
trustedOrigins, and fails closed — a request with neither header is a 403.
Routes
Mounted at basePath (default /api/r3).
| Route | Upstream | Cookies touched |
| --- | --- | --- |
| POST /login | POST /consumer/auth/login | sets access + refresh + profile; fills guest email |
| POST /google | POST /consumer/auth/google | sets access + refresh + profile; fills guest email |
| POST /register | POST /consumer/auth/register | none — registration never opens a session |
| POST /logout | POST /consumer/auth/logout-all (only with { everywhere: true }) | clears access + refresh + profile |
| GET /session | POST /consumer/auth/refresh (only when stale) | may re-set access + refresh + profile; mints guest |
| POST /magic-link/request | POST /consumer/auth/request-login | none |
| POST /magic-link/confirm | POST /consumer/auth/confirm-login | sets access + refresh + profile |
| POST /password-reset/request | POST /consumer/auth/request-reset | none |
| POST /password-reset/confirm | POST /consumer/auth/confirm-reset | clears access + refresh + profile |
| POST /verify-email | POST /consumer/auth/verify-email | sets access + refresh + profile |
| POST /verify-email/request | POST /consumer/auth/request-verification | none |
Failures come back as the platform's envelope — { "error": "<code>", "message":
"…" } — with the platform's status. On the server they are the same typed
errors @r3pos/ecom throws, re-exported here so one catch covers everything:
import { isConflictError, isRateLimitError } from '@r3pos/ecom-next';Two notes on behaviour you should not "fix"
Registration does not sign anyone in. The platform gates sign-in behind a
verified email, so POST /register returns { verificationRequired, email } and
no session. Send the shopper to their inbox; the emailed link lands on
POST /verify-email, which verifies and signs them in. (verificationRequired
is false in exactly one case: the address already belonged to a verified
guest-checkout shopper who has just set a password, so they can sign in
immediately.)
Login is deliberately uninformative. An unknown email and a wrong password
produce the same 401, after the platform has done the same argon2 work in both
cases. The adapter passes that through byte for byte and adds nothing —
/magic-link/request, /password-reset/request and /verify-email/request
likewise always answer { ok: true } whether or not the address exists. Do not
add a "no account found" branch on top: it would turn all of that back into an
account-enumeration oracle.
POST /logout
Clears this browser's cookies, with no upstream call. Pass
{ everywhere: true } to also revoke the shopper's sessions on their other
devices. The guest cookie survives either way — signing out is "this is not my
account right now", not "forget me".
Sign in with Google
Google sign-in produces the same session as email and password. Same three
httpOnly cookies, same flags, same refresh path — there is no second session
mechanism, because a second one would be a second set of rules to keep in step
and the neglected one would be the leak.
This package does not bundle or wrap Google's SDK. You render the button and hand the adapter the ID token Google gives you. That keeps a third-party script out of every storefront that installs this, and leaves you free to use the button, One Tap, a custom flow, or a native app's Google sign-in — they all produce the same ID token.
1. Get the client ID
Create an OAuth 2.0 Client ID of type Web application in the Google Cloud
console, with your
storefront's origin as an Authorized JavaScript origin. It looks like
123456789-abc.apps.googleusercontent.com.
Then register it in R3 under Settings → Developers → Google sign-in. That
registration is what makes the tokens your site obtains recognisable: the API
checks the token's aud claim against the client ids it knows, and an
unregistered aud is refused with 401, always.
If you cannot run a Google project, ask R3 to enable R3's own client for your domain. Leaving the field blank does not do this by itself: using R3's client means R3 has to add your storefront's domain to that client's Authorized JavaScript origins, which only R3 can do, so it is granted per store rather than assumed. Until you register your own or R3 enables theirs, Google sign-in is off for your store. Two things to know before choosing R3's: Google's consent screen will show R3's name rather than yours, and changing your domain later means telling R3 first.
2. Read the client ID rather than hardcoding it
import { r3 } from '@/lib/r3';
// A Server Component, a layout, or anywhere on the server.
const googleClientId = await r3.googleClientId();
// string → render the Google button with it
// null → this store has no Google sign-in; render NO buttonr3.googleClientId() is one GET /ecom/v1/merchant read. The server resolves
which client is in force (yours if registered, else R3's if enabled for you, else
none), so switching Google projects later is a settings change rather than a
redeploy — and your site and the platform cannot end up disagreeing about it,
which otherwise surfaces as every sign-in failing with a bare 401.
Handle the null. It is a normal state, not an error, and there is nothing
to fall back to: a button pointed at a client your store is not entitled to use
dies inside Google with an opaque origin error your users cannot diagnose and you
cannot see.
The client ID is public: it ships in the page by design, so passing it to a
client component is fine. There is no Google client secret in this flow — R3
verifies ID tokens against Google's public keys and never asks for one. (Your R3
secret key still never goes near a NEXT_PUBLIC_ variable.)
3. Render the button and pass the token through
'use client';
import Script from 'next/script';
import { useShopper } from '@r3pos/ecom-next/react';
export function GoogleButton({ clientId }: { clientId: string }) {
const { signInWithGoogle } = useShopper();
return (
<>
<Script
src="https://accounts.google.com/gsi/client"
onReady={() => {
google.accounts.id.initialize({
// The id from step 2 — passed in as a prop, not read from an env
// var, so the platform stays the one place that decides it.
client_id: clientId,
// `credential` IS the ID token. It is the only Google value that
// crosses into this package, and it crosses once.
callback: ({ credential }) => void signInWithGoogle({ idToken: credential }),
});
google.accounts.id.renderButton(document.getElementById('g-btn')!, { theme: 'outline' });
}}
/>
<div id="g-btn" />
</>
);
}signInWithGoogle posts the token to your own POST /google route, which
forwards it to the platform along with the tenantId this adapter was configured
with — R3 needs to know which store's settings to check, and a browser does not
get to choose that, so a tenantId in the incoming request body is ignored. The platform verifies it server-side — RS256
against Google's JWKS, plus iss, aud, exp and iat — and answers with an
ordinary consumer session. The R3 tokens go straight into the httpOnly
cookies; the browser never holds one, and the ID token is never stored, logged
or echoed back.
What the platform does with the account
- The token's
subalready matches a shopper ⇒ that is the account. - Otherwise, if Google says the email is verified and it matches an existing shopper ⇒ the accounts are linked. Email is the identity in this platform, so creating a second shopper for someone who already has orders, stamps and saved cards under that address would strand all of it behind an account they cannot reach.
- Otherwise ⇒ a new shopper, with the email already verified.
An unverified Google email is refused with 403 email_unverified — anyone
can attach an arbitrary address to a Google account, and honouring one would
hand over any shopper's identity for the price of typing their email. Handle
that code the same way you already handle it on password login.
Why the middleware matters
Next.js refuses cookie writes inside a React Server Component. The platform revokes a refresh token the moment it is exchanged, with no grace window. Put those together and refreshing from a Server Component would hand the browser a token the server has already killed.
So the session engine refreshes only where it can persist the result. That means:
- With the middleware mounted — it runs before the render, in a context that can write, and rolls the 15-minute access token over ahead of time. Everything downstream just works.
- Without it — a Server Component with an expired access token still
correctly reports the shopper as signed in (they hold a live refresh token),
but
r3.orders.list()from that render will throwsession_refresh_required, with a message naming this fix. Nothing is silently broken and nothing is silently destroyed.
Refreshes are single-flight: five components reading the session on one page perform one refresh, not five. Five would mean four replays of a revoked token, which the platform answers by revoking the shopper's entire chain — signing them out of every device.
Sessions
const session = await r3.getShopperSession();ShopperSession is a discriminated union, so session.shopper does not
typecheck until you have narrowed:
if (session.status === 'authenticated') {
session.shopper.displayName; // ✅
session.guestContinuity; // were their guest orders placed with this email?
} else {
session.email; // the address a guest checkout recorded, if any
}It carries no credential, which is what makes it safe to pass into a Client Component, log, or serialise.
Cached per request via React's cache, so reading it in five components costs
one resolution.
Guests
A guest gets a stable id in its own cookie, minted on first need:
const guestId = await r3.ensureGuestId();It returns null when called from a Server Component that has no guest cookie
yet — Next.js will not let a Server Component write the cookie that would make
the id stick, and an id that changes on every render is worse than none. Call it
from a Server Action or Route Handler, or mount the middleware, and it always
returns a string.
Guest → account continuity
The platform already keys a shopper's orders on email: a guest checkout resolves (or creates) the consumer row for that address, and registering later with the same address claims that same row. Their guest orders were always theirs.
The adapter's job is only to carry the email through so your storefront can say so. Record it at checkout:
'use server';
await r3.rememberGuestEmail(email);and when that shopper signs in, the session reports
guestContinuity: true. There is no link table here, deliberately — it would be
a second, weaker source of truth for something the platform already decides
correctly.
Order history
Server-side only. The browser never sees the consumer token.
// app/account/orders/page.tsx — a Server Component
import { r3 } from '@/lib/r3';
export default async function OrdersPage() {
const orders = await r3.orders.list({ limit: 20 });
return <OrderList orders={orders} />;
}r3.orders.list(params?)— this merchant's orders for the signed-in shopper, newest first, with full line items.r3.orders.get(id)— one order's full receipt. An order the shopper placed at a different café on the platform is a 404 here: true for your storefront, and the only answer that does not leak where else they shop.
Both throw R3EcomAuthError (not_signed_in) when nobody is signed in.
Saved addresses
Server-side only, like order history. A signed-in shopper's address book:
// app/checkout/page.tsx — a Server Component
import { r3 } from '@/lib/r3';
export default async function CheckoutPage() {
const session = await r3.getShopperSession();
if (session.status !== 'authenticated') return <GuestCheckout />;
const addresses = await r3.addresses(session).list();
return <AddressPicker addresses={addresses} />;
}| | |
| --- | --- |
| list() | every saved address — default first, then newest first. Unpaginated |
| create(input) | line1 and country required; country is ISO-3166-1 alpha-2 in any case. The first address a shopper saves becomes their default whatever isDefault says |
| update(id, changes) | an omitted field is left alone; line2 and phone accept null to clear |
| remove(id) | deleting the default promotes the next-newest, so the book is never left without one |
| setDefault(id) | promotes one and demotes the previous, in one operation |
A guest has no address book, and the compiler knows
r3.addresses(session) takes an AuthenticatedShopperSession — not the
ShopperSession union. Passing an un-narrowed session is a build failure, not a
401 discovered later on somebody's checkout page:
const session = await r3.getShopperSession();
r3.addresses(session); // ❌ does not compile
if (session.status === 'authenticated') r3.addresses(session); // ✅The book belongs to the shopper, not to you
An address is the consumer's, not the merchant's: a person's home address is the same at every café on the platform, and there is deliberately no merchant-facing route into that table. You see an address when the shopper attaches one to an order, at which point it is your order data.
Ownership is enforced upstream, on the consumer id in the verified token rather
than on anything in the URL. Another shopper's address id is a 404 —
indistinguishable from an id that never existed, and passed straight through
here rather than softened into null. "Not found" and "not yours" must stay
the same answer.
Reorder
r3.draftReorder(orderId) rebuilds a past order as a priced draft.
const draft = await r3.draftReorder(order.id);
if (draft.hasPriceChanges || draft.hasUnavailableLines) {
return <ReviewChanges draft={draft} />; // show them, then let the shopper decide
}It creates nothing and charges nothing. There is no order id on a
ReorderDraft — only sourceOrderId, naming the order it was rebuilt from.
Placing it is a separate, explicit step:
await r3.ecom.orders.create({ items }); // built from the draft's linesThat separation is the whole point. Both shortcuts it refuses are money bugs: re-selling a six-month-old basket at last year's stored prices loses the merchant money that grows with the age of the order, and re-pricing it silently charges the shopper a total they never saw. So every line is re-resolved server-side against today's catalog, by the same pricing core that prices a live cart, and the differences come back for you to show:
| per line | |
| --- | --- |
| unitPriceCents | today's price. null when the line cannot be re-bought |
| previousUnitPriceCents | what the original order charged. Always present, including on unavailable lines |
| priceChanged | the two differ — show both numbers |
| available / archived | can it be re-bought; did the merchant retire it, or is it just out of stock today |
| unavailableReason | product_deleted, product_archived, product_unpublished, product_unavailable, modifier_unavailable, invalid_product, line_invalid |
| selectedOptions | the historical modifier choices re-resolved onto today's option ids — post these back verbatim to place the cart |
subtotalCents / taxCents / totalCents cover the available lines only,
at your current tax rate, and carry no tip — a tip is chosen at checkout.
hasUnavailableLines and hasPriceChanges are the two booleans a UI branches
on.
Unlike order history, this runs on your key (orders:write), because it
prices against your live catalog — so it goes over the same transport as the
rest of r3.ecom.
The rest of the platform
r3.ecom is the full @r3pos/ecom
server SDK on the same configuration — catalog, inventory, orders, webhooks,
merchant profile:
const { data: products } = await r3.ecom.catalog.products.list({ limit: 50 });
const order = await r3.ecom.orders.create({ items });Configuration
createR3Ecom({
apiKey, // required — SECRET key (r3_sk_…). A publishable key throws.
baseUrl, // required — API origin, no path
tenantId, // required — scopes order history to your store
basePath, // default '/api/r3'
trustedOrigins, // extra origins allowed to make mutating requests
cookiePrefix, // default 'r3_shopper'
cookieDomain, // unset ⇒ host-only, the safer default
cookiePath, // default '/'
secure, // default: on in production
sessionMaxAgeSeconds, // default 30 days, matching the platform's refresh TTL
guestMaxAgeSeconds, // default 1 year
refreshSkewSeconds, // default 60 — refresh this long before expiry
timeoutMs, // default 15000
forwardShopperIp, // default true — see below
fetch, now, mintGuestId, // injectable, for tests
userAgent,
});Build one per process and share it. Two adapters cannot coordinate a rotation between them.
apiKey must be the secret key
A publishable key throws at construction. It is refused rather than tolerated
because this adapter is your backend: it holds the session cookies and reads
order history, which needs the orders:read scope a publishable key can never
hold. A storefront built on one would come up, serve a catalog, take an order,
and then 403 the first time a shopper opened their order history — weeks later,
looking like a permissions bug.
Keep it in a server-side environment variable. Never one prefixed
NEXT_PUBLIC_, which Next.js inlines into the browser bundle.
forwardShopperIp
On by default. Forwards the shopper's address to the platform so its per-IP rate limits count per shopper, rather than lumping your entire storefront into the one bucket belonging to your server.
The address comes from the incoming request's X-Forwarded-For (first hop) or
X-Real-IP, so it is exactly as trustworthy as those headers are in your
deployment: authoritative behind a proxy that sets them (every managed Next.js
host does), client-controlled on an app exposed directly to the internet. Turn
it off in the latter case — the cost is one shared rate-limit bucket for every
shopper, which a busy storefront will notice.
cookieDomain
Leave it unset unless you need one session across subdomains. Setting it to a
parent domain makes a hostile sibling subdomain same-site, so SameSite=Lax
stops covering you there and the origin check becomes your only CSRF control —
which is precisely why that check exists.
CSRF: strict origin checking
Every state-changing request must carry an Origin (or, failing that, a
Referer) whose origin is your storefront's own or one you listed in
trustedOrigins. Anything else, including a request with neither header, is a
403 — before the body is read and before anything upstream is touched.
Why not a double-submit token? It has to be readable by the page that echoes
it, which means either a cookie without HttpOnly — punching a hole in the exact
property this package exists to guarantee — or an extra round trip before every
sign-in, which is a piece of wiring every merchant then has to get right. Origin
checking needs neither: the browser sets the header, script cannot forge it, and
mounting the route handler is the whole integration.
It is also not the only control. The session cookies are SameSite=Lax, so a
cross-site POST does not carry them in any current browser and the forged request
arrives unauthenticated. The origin check covers the case SameSite=Lax does
not: a hostile page on a sibling subdomain, which matters as soon as you set
cookieDomain.
If a proxy in front of your app strips both headers, you will see 403s — the
correct, loud failure. Add your storefront's public origin to trustedOrigins.
Testing your integration
Everything is injectable, so your own tests need no network:
const r3 = createR3Ecom({
apiKey: 'r3_sk_test_…',
baseUrl: 'https://stub.invalid',
tenantId: 'test-tenant',
fetch: myFakeFetch,
now: () => 1_800_000_000_000,
mintGuestId: () => 'guest-1',
secure: true,
});Coming next
Changing a password from inside a signed-in session is not exposed yet, though the platform endpoint exists; it is scoped for the next pass.
License
MIT © R3 Lab
