@reblu/site-next
v1.36.0
Published
Next.js RSC glue for Reblu SITEs — cache + CTA + auth server/client components.
Readme
@reblu/site-next
Next.js glue for Reblu SITEs (REB-418, REB-510). Five responsibilities, all
intentionally Next-coupled so the lower SDK layers —
@reblu/site-client, @reblu/site-contracts,
@reblu/site-ui — stay framework-agnostic:
NextCacheAdapter— implements@reblu/site-client'sRebluCacheAdapter, translating a cache policy into Next ISRfetchoptions.- Editable-CTA glue — the
EditableCtaasync Server Component, itsCtaButtons'use client'boundary, andStickyMobileCta. - Auth (
./auth) +createProxyRoute(./route-handlers) — the client auth components (LoginForm, password reset, set-password, email confirm, MFA step-up,AuthLogo) and the server-only Route Handler factory that proxies them to base-api's/api/site/auth/*. <JsonLd>(./json-ld) — safeapplication/ld+json<script>rendering; see "Structured data (JSON-LD)" below.- reCAPTCHA v3 (
./recaptcha,./recaptcha-provider) — the SITE's half of the contact-form captcha contract: the canonical action, the render gate, and a defect-fixed (REB-793) interaction-mount provider,<DeferredRecaptchaProvider>. See "reCAPTCHA v3" below. - Academy (
./academy,./academy-server) —AcademyEnrollmentForm, the anonymous enrollment-request form, plusderiveValidationFieldsto build its per-tenant validation fields from course/config data, andgetLessonPreview(REB-1135), the server fetcher for a public free-preview lesson. See "Academy" below. - Shop filters (
./shop,./shop-server) — URL ↔ShopProductsParamsglue for@reblu/site-blocks'sShopFilters: parse/serialize, a server helper that fetches products + facets with identical params, and a client-reactive navigation hook. See "Shop filters" below.
Install
pnpm add @reblu/site-next @reblu/site-client @reblu/site-contracts @reblu/site-ui next-themesPeer deps: react >=19, next >=16, @reblu/site-ui, next-themes, and
optionally react-google-recaptcha-v3 (only if you use
@reblu/site-next/recaptcha-provider — see "reCAPTCHA v3" below).
next-themes is required by the ./auth entry as a whole, not only by
AuthLogo: tsup.config.ts builds auth.js/auth.cjs as ONE
self-contained bundle (splitting: false, treeshake: false, both required
to preserve the 'use client' directive — R4), so importing anything from
@reblu/site-next/auth pulls in AuthLogo's next-themes import at module
resolution time, even if your code never renders <AuthLogo/>.
Testing a SITE with plain Node ESM (vitest)
Since 1.20.1 the ESM build imports next/link.js, next/navigation.js and
next/script.js (Next ships no exports map, so plain Node ESM needs the
extension). A SITE's vitest config no longer needs
test.server.deps.inline: [/@reblu\/site-next/] to render SDK components; remove
that workaround when you upgrade. The same fix ships in @reblu/site-ui for
./next and ./pagination.
ISR caching
Wire the adapter once when you build the SDK client — this restores the exact ISR behavior a SITE would otherwise hard-code, now injected:
import { createRebluClient } from '@reblu/site-client'
import { NextCacheAdapter } from '@reblu/site-next'
export const client = createRebluClient({
baseUrl: process.env.REBLU_API_URL!,
apiKey: process.env.REBLU_API_KEY!,
cache: new NextCacheAdapter(60), // default ISR window in seconds
})| Policy revalidate | Emitted fetch init |
| ------------------- | ---------------------------- |
| > 0 | { next: { revalidate } } |
| false / 0 | { cache: 'no-store' } |
| omitted | the constructor default |
One fetch per read per request — rebluRequestScope (REB-1214)
Pass rebluRequestScope as the client's requestScope so identical GET reads
within one server request (generateMetadata plus the page body, a layout plus
a page) cost one base-api call. It is a React cache() Map, scoped to one
request, so nothing is shared between visitors. It ships from the server-safe
@reblu/site-next/cache entry (and the root barrel):
import { createRebluClient } from '@reblu/site-client'
import { NextCacheAdapter, rebluRequestScope } from '@reblu/site-next/cache'
export const client = createRebluClient({
baseUrl: process.env.REBLU_API_URL!,
apiKey: process.env.REBLU_API_KEY!,
cache: new NextCacheAdapter(300),
requestScope: rebluRequestScope,
})Outside a React server render (a Route Handler, for example) cache() does not
memoise, so reads there are not deduped. The client also retries a read
answered 429/503 once, honouring Retry-After up to 2 s; see the
@reblu/site-client README.
Cached reads with on-publish revalidation (REB-1215)
Keep content reads cached and let base-api expire them when content
changes, instead of reading with no-store. A no-store read reaches base-api
on every render (bots included), so the tenant's API load follows traffic
instead of content edits.
Tag every cached read with
reblu:content, plus a safety time-basedrevalidatewindow (default 1 h) for a missed delivery:import { createRebluClient } from '@reblu/site-client' import { createCachedReadAdapter } from '@reblu/site-next/cache' export const client = createRebluClient({ baseUrl: process.env.REBLU_API_URL!, apiKey: process.env.REBLU_API_KEY!, cache: createCachedReadAdapter(), // cached 1 h, tagged 'reblu:content', 404s included })A read with
{ revalidate: false }staysno-store; keep that for what must be live per visitor (session, cart, previews). The SDK already reads blog and lesson previews andshop.orderthat way.What the window guarantees (REB-1245). The adapter caches each read's outcome, a 404 included. The base-api call runs
no-storeinside Next'sunstable_cache, under thereblu:contenttag and therevalidatewindow. So, with no webhook at all:- once the window expires, the next request may still get the old page (stale-while-revalidate) while the refresh runs; if base-api now answers 404 the refresh stores that, and the page is gone from the request after that one. A removed page lives at most one window plus one request;
- a 5xx, a 429 or a network error is never stored: Next keeps serving the
last good value (a page, or a 404), and retries on a later request. With
nothing cached yet, the error reaches your code as an
ApiError, as before.
Why not just the fetch cache: Next 16 stores a fetch response only when it is a 200 (
patch-fetch.js), so a detail that turned 404 kept its old 200 with no time limit, and the window was no ceiling at all. Whyunstable_cacheand not'use cache'+cacheTag+cacheLife:'use cache'only works when the SITE turns oncacheComponentsinnext.config, which changes how every route renders, and it needs Next's compiler to transform this package's code.unstable_cacheworks in any Next 16 app, and it uses the same incremental cache and tag manifest as'use cache', sorevalidateTag(tag, { expire: 0 })andrevalidateTag(tag, 'max')behave the same on it (expire: the next read refetches and waits;'max': stale-while-revalidate). The choice stays behind the adapter, so moving to'use cache'later changes no SITE code. Outside a Next server (a script, a unit test) the read is a plainfetch. Undernext dev, normal navigation caches the same way, but a request that sendsCache-Control: no-cache(a hard reload) skips the cache: it reads fresh, and a 5xx shows as an error page instead of the last good one.Do not mix it with your own
next: { revalidate }fetches of the same endpoints: those still use the fetch cache and keep the 200-only gap.Mount the signed webhook handler at exactly
/api/reblu/revalidate:// app/api/reblu/revalidate/route.ts import { createRevalidateRouteHandler } from '@reblu/site-next/revalidate' export const POST = createRevalidateRouteHandler()Set
REBLU_REVALIDATE_SECRETto the tenant's revalidation secret (issued by Reblu staff). On publish, update, unpublish or delete, base-api POSTs{ id, kind, tags }(tags only, never content) signed withx-reblu-timestampandx-reblu-signature: v1=<HMAC-SHA256(secret, "<timestamp>.<body>")>. The handler:- answers 401 and revalidates nothing on a bad or missing signature, a timestamp more than 5 minutes off, a replayed delivery id, or a missing secret;
- answers 400 to a correctly signed body with an unknown
kindor tags that are notreblu:*; - otherwise revalidates each tag by
kind:unpublish/deletecallrevalidateTag(tag, { expire: 0 }), so a removed page returns 404 on the next request;publish/updatecallrevalidateTag(tag, 'max')(stale-while-revalidate), so a failed regeneration keeps serving the last good page and a visitor never gets an error page because of an edit.
Replay protection is per server instance; a replay that reaches another instance inside the window only repeats the same idempotent revalidation.
Editable CTAs
EditableCta is a drop-anywhere Server Component that renders the CTAs configured
for a slot in nexus, with no redeploy. It fetches via the injected SDK client and
degrades gracefully to fallback on empty/failure:
import { EditableCta } from '@reblu/site-next'
import { client } from '@/lib/reblu'
<EditableCta slot="hero" client={client} fallback={<StaticHeroCta />} />Slots: header, hero, blog-end, services, contact, sticky-mobile,
footer. A persistent header CTA, for example:
import { CalendarIcon } from 'lucide-react'
<EditableCta
slot="header"
client={client}
size="sm"
icon={<CalendarIcon />}
primaryVariant="default"
secondaryVariant="outline"
primaryClassName="btn-cta" // optional: map the primary CTA to your own button style
fallback={<StaticHeaderCta />}
/>EditableCta forwards every CtaButtons presentation prop:
| Prop | Default | Effect |
| -------------------- | ---------------------------------- | ------------------------------------------------------- |
| className | — | Wrapper class (layout of the button group) |
| buttonClassName | — | Class on every button |
| size | 'lg' | @reblu/site-ui Button size |
| icon | — | Leading icon on every CTA, aria-hidden; an element icon is a direct child, so has-[>svg] padding applies |
| primaryVariant | 'default' | @reblu/site-ui Button variant of the first CTA |
| secondaryVariant | 'default' | Button variant of every other CTA |
| primaryClassName | — | Extra class on the first CTA, after buttonClassName |
| secondaryClassName | — | Extra class on every other CTA, after buttonClassName |
| newWindowHint | '(se abre en una nueva ventana)' | Visually-hidden text on CTAs that open in a new tab |
CTAs with an absolute http(s) href open in a new tab
(target="_blank" rel="noopener noreferrer") and carry the newWindowHint in a
sr-only span, so screen readers announce the new window. The primary CTA is the
first one by sortOrder.
CtaButtons (the 'use client' renderer) and StickyMobileCta are also exported.
CtaButtons is additionally available at the @reblu/site-next/cta-buttons
subpath, which resolves directly to the emitted 'use client' boundary.
Auth
@reblu/site-next/auth exports the copy-injectable client components a SITE
mounts at its auth pages, each with Spanish-default copy overridable via a
copy prop:
import { LoginForm, AuthLogo, mapLoginError } from '@reblu/site-next/auth'
<AuthLogo
logoLightExpandedUrl={config.logoLightExpandedUrl}
logoDarkExpandedUrl={config.logoDarkExpandedUrl}
logoLightSmallUrl={config.logoLightSmallUrl}
logoDarkSmallUrl={config.logoDarkSmallUrl}
siteName={config.siteName}
/>
<LoginForm
passwordLoginUrl="/api/auth/login"
mfaVerifyUrl="/api/auth/mfa-verify"
magicLinkRequestUrl="/api/auth/magic-link-request"
resetPasswordUrl="/reset-password"
/>| Export | Mount path (fixed by base-api) | Notes |
| ------------------------- | ----------------------------------- | ----------------------------------------- |
| LoginForm | /login | Password + magic-link + MFA; the 6-digit MFA code verifies itself (REB-1281) |
| AuthCallback | Supabase email-template target | No AuthLogo (transient, R1 exception) |
| ResetPasswordRequest | SITE-owned (e.g. /reset-password) | Anti-enumeration generic success; posts to its own requestUrl prop |
| ResetPasswordConfirm | /reset-password/confirm | Includes REB-378 MFA step-up branch |
| SetPassword | /auth/set-password | Invite flow, session_exchange_token |
| AuthConfirm | /auth/confirm | Dual-mode: success=true / token_hash; type=email_change shows emailChangeSuccess* copy |
| AuthLogo | — | Dynamic tenant logo, next-themes-driven |
| mapLoginError | — | Maps Supabase ?error= to Spanish copy |
Recovering from an expired magic link (REB-762 / REB-763)
A SITE may render LoginForm on the same page as AuthCallback, so someone who
arrives with a dead link can ask for a new one without leaving. Two things make
that path work, and both are on by default — no wiring in the SITE:
- A successful request clears the callback's verdict.
LoginFormdispatches thereblu:magic-link-requestedDOM event onwindow;AuthCallbacklistens and drops a terminal verdict about the old link ("Enlace caducado", "Enlace inválido", or a failed exchange), rendering nothing so the form's own message is the only one on screen. Both components stay independently usable: alone, one dispatches to nobody and the other hears nothing.emitMagicLinkRequestedandonMagicLinkRequestedare exported if a SITE wants to join in. - A 60-second cooldown on the form. Supabase Auth drops a second magic link
requested inside its per-project "minimum interval per user" before any email
is sent, while the request route still answers 200. The success panel holds
the form for
MAGIC_LINK_COOLDOWN_SECONDSwith a visible countdown, then offers "Pedir otro enlace". The interval is the Supabase project's setting, not ours —MAGIC_LINK_COOLDOWN_SECONDSmirrors it in one place, and a SITE whose project uses a different value passescooldownSeconds.
ResetPasswordConfirm, SetPassword, and AuthConfirm read their own URL
params internally via useSearchParams(). Next.js requires that hook's
subtree to render inside a <Suspense> boundary — wrap the page/route that
mounts one of these three in <Suspense> yourself (LoginForm and
AuthCallback don't need it: they don't call useSearchParams()).
createProxyRoute (@reblu/site-next/route-handlers, server-only,
dependency-free) wires a Route Handler file to base-api in one line:
// app/api/auth/login/route.ts
import { createProxyRoute } from '@reblu/site-next/route-handlers'
export const POST = createProxyRoute('/api/site/auth/login')baseUrl/apiKey resolve from opts first, falling back to the
REBLU_API_BASE_URL/REBLU_API_KEY env vars — read lazily per request, not at
module load.
Academy
@reblu/site-next/academy exports the anonymous academy enrollment-request
form (AcademyEnrollmentForm) and re-exports the pure helper that derives its
validation fields from tenant data (deriveValidationFields).
@reblu/site-next/academy-server exports ONLY server-safe code: that pure helper and
the getLessonPreview fetcher (REB-1135, see below).
Which subpath to import from matters. ./academy carries a leading
'use client' directive — required so AcademyEnrollmentForm survives the
build — and the whole entry (helper included) is one directive-covered chunk.
Calling deriveValidationFields imported from ./academy inside a Server
Component throws at runtime ("Attempted to call deriveValidationFields() from
the server but deriveValidationFields is on the client"), because Next's RSC
boundary sees the whole entry as client-only. A course detail page is
typically an async Server Component computing validationFields to pass down
as a prop — exactly the case that needs the server-safe subpath:
// app/academy/[slug]/page.tsx — an async Server Component
import { AcademyEnrollmentForm } from '@reblu/site-next/academy'
import { deriveValidationFields } from '@reblu/site-next/academy-server'
// `course` is whatever `GET /api/site/academy/courses/[slug]` (via
// `@reblu/site-client`'s `academy.bySlug`) already returned — this form never
// re-derives `salesEnabled` or requests anything extra.
const validationFields = deriveValidationFields(course)
{course.salesEnabled && (
<AcademyEnrollmentForm
enrollmentRequestUrl="/api/academy/enrollment-requests"
courseSlug={course.slug}
validationFields={validationFields}
/>
)}Import deriveValidationFields from ./academy instead only when the call
site is already inside a 'use client' boundary (e.g. computing it alongside
other client-side state) — ./academy still re-exports it for that case and
for call sites that prefer one import statement for both.
enrollmentRequestUrl is a SITE-owned Route Handler path — /api/academy/
enrollment-requests above is illustrative; the SITE chooses its own local
route. The browser can never call base-api directly (X-Reblu-Api-Key is a
server secret), so that local route proxies to the platform endpoint,
POST /api/site/academy/enrollment-requests. Wire it with createProxyRoute
from ./route-handlers:
// app/api/academy/enrollment-requests/route.ts
import { createProxyRoute } from '@reblu/site-next/route-handlers'
export const POST = createProxyRoute('/api/site/academy/enrollment-requests')Signed-in visitors (REB-1133). Pass sessionUrl (the SITE's
createSiteSessionRoute() path, see "Signed-in checkout" below) and a signed-in
visitor is not asked for an email. The form shows «Te inscribes como
{email}» instead, plus a «¿No eres tú? Cerrar sesión» link when signOutUrl
is set. Both texts can be changed with copy.signedInAs and
copy.signOutCta. base-api identifies the visitor by the session cookies,
never by a typed email, so the enrollment proxy must forward them:
// app/api/academy/enrollment-requests/route.ts
export const POST = createProxyRoute('/api/site/academy/enrollment-requests', {
forwardAuthCookies: true,
})
// the course page
<AcademyEnrollmentForm
enrollmentRequestUrl="/api/academy/enrollment-requests"
courseSlug={course.slug}
validationFields={validationFields}
sessionUrl="/api/auth/me"
signOutUrl="/logout"
/>signOutUrl must point to a sign-out route that the SITE itself provides:
the SDK ships none. A link to a path that does not exist leaves the visitor
signed in on a 404.
For a signed-in visitor base-api files the request directly, with no sign-in link: the same rules as the client portal (an approved person only owes the fields this course still needs; a request already in review is not duplicated). Its answer says what happened, and the form shows it:
| status | Shown (copy key) |
| --- | --- |
| requested | «Solicitud enviada. Te avisaremos por correo cuando esté revisada.» (requestedMessage) |
| enrolled | «Ya estás inscrito en este curso.» (enrolledMessage) |
| checkout | «Tu acceso está aprobado. Completa el pago para empezar el curso.» (checkoutMessage) plus an «Ir al pago» link to checkoutUrl (checkoutCta; only an http(s) URL is rendered) |
Without sessionUrl, or with nobody signed in, the form asks for the email
exactly as before, and base-api keeps its uniform answer with no status.
Outcome and next steps (REB-1208). Two optional props; a SITE that passes neither renders exactly as before.
onOutcome({ outcome })fires once afteronSubmittedwith'received' | 'enrolled' | 'checkout', and nothing else: no email, no server message.'received'covers both the anonymous uniform answer (new or already-known email, indistinguishable by design) and a signed-inrequested, so the SITE learns no more than the visitor sees. Only a signed-in visitor can get'enrolled'or'checkout'.onSubmitted(email)keeps its one-argument call.nextStepsadds an ordered «Próximos pasos» list under the success message, only for'received'; an enrolled or approved-to-pay visitor never sees it.trueuses the generic defaults (DEFAULT_ACADEMY_ENROLLMENT_NEXT_STEPS: «Solicitud recibida» → «Revisión» (current) → «Pago y acceso»). An object overrides any ofheading,steps({ title, description? }[]) andcurrentStep(zero-based; steps before it are done, it carriesaria-current="step"). It is plain data, so a Server Component can pass it. A free course should pass its own steps: the default third step mentions payment.
<AcademyEnrollmentForm
enrollmentRequestUrl="/api/academy/enrollment-requests"
courseSlug={course.slug}
validationFields={validationFields}
nextSteps={{
steps: [
{ title: 'Solicitud recibida', description: 'Te escribimos por correo.' },
{ title: 'Revisión', description: 'Comprobamos los datos de tu solicitud.' },
{ title: 'Pago y acceso', description: 'Cuando se apruebe, te enviamos el enlace de pago.' },
],
}}
/>A SITE that draws its own confirmation page can render the same list
directly with AcademyEnrollmentNextSteps (same props, plus className),
from onOutcome in a client component:
'use client'
import { useState } from 'react'
import {
AcademyEnrollmentForm,
AcademyEnrollmentNextSteps,
type AcademyEnrollmentOutcome,
} from '@reblu/site-next/academy'
export function Enrollment({ courseSlug }: { courseSlug: string }) {
const [outcome, setOutcome] = useState<AcademyEnrollmentOutcome | null>(null)
return (
<>
<AcademyEnrollmentForm
enrollmentRequestUrl="/api/academy/enrollment-requests"
courseSlug={courseSlug}
onOutcome={(r) => setOutcome(r.outcome)}
/>
{outcome === 'received' && <AcademyEnrollmentNextSteps />}
</>
)
}PER-TENANT, DATA-DRIVEN — not code-driven. Whether a course asks for extra verification, and which fields it asks for, is entirely tenant configuration:
- Today,
AcademyCourseDetailexposes onlyrequiresValidation+validationFieldLabel(a single generic field).deriveValidationFieldsfalls back to that one field when no richer list is present. - A future tenant-configurable list (e.g.
AcademyConfig.validationFields: [{ key, label, required, maxLength? }]) is accepted by the SAME helper —AcademyValidationSourceis defined structurally, so a wider real contract type is already assignable without an SDK release. Arbitrary keys (a colegiado number, a professional college, anything a tenant configures) work the moment the data carries them;AcademyEnrollmentFormnever hardcodes a field name — it only ever iterates thevalidationFieldsarray it was given. - A tenant with no validation requirement passes
validationFields={[]}(or omits the prop,[]is the default) — the form renders only the email field.
Never a purchase confirmation. The upstream route
(POST /api/site/academy/enrollment-requests) replies UNIFORMLY whether the
email was brand-new (a request row was created) or already known (a sign-in
link was sent, nothing created) — anti-enumeration by design. The form's
always-visible copy says the request goes to human review and that no
automatic charge happens; onSubmitted fires on any 2xx and must never be
read as "a request was recorded".
Field-level errors. A non-2xx JSON body shaped like { error, details:
{ fieldErrors } } (ApiErrorBody/ApiErrorDetails from @reblu/site-client)
maps each fieldErrors key onto the matching input (email, or a
validationFields[].key) and focuses the first one; anything else — no
details, or a 429 — renders as a single form-level error instead. No
reCAPTCHA: the route is IP rate-limited server-side.
Guest course checkout — createAcademyCheckoutRoute (REB-1264)
A course that needs no validation is bought in one step by a visitor without
a session; the account is created after payment. Mount the route the drop-in
CourseGuestCheckoutForm (@reblu/site-blocks/client) posts to:
// app/api/academy/checkout/route.ts
import { createAcademyCheckoutRoute } from '@reblu/site-next/route-handlers'
export const POST = createAcademyCheckoutRoute() // course page at /academy/{courseSlug}
// or: createAcademyCheckoutRoute({ coursePath: '/cursos/{courseSlug}' })Like createShopCheckoutRoute: the API key stays server-side, the visitor IP
travels as x-reblu-visitor-ip (base-api's per-visitor checkout limit), the
missing successUrl/cancelUrl are built on coursePath
(?pago=ok&session_id={CHECKOUT_SESSION_ID} / ?pago=cancelado; override
with successPath/cancelPath, {courseSlug} is filled in), and the Stripe
URL is re-checked (502 unsafe_redirect otherwise). Refusals such as
ENROLLMENT_REQUEST_REQUIRED pass through unchanged. No cookies travel
either way: forwardAuthCookies/forwardSetCookie are not options here.
Free-preview lessons — getLessonPreview (REB-1135)
@reblu/site-next/academy-server also exports getLessonPreview(client,
courseSlug, lessonSlug): the server fetcher for a lesson the owner marked «Vista
previa gratuita», viewable by visitors who have not paid or enrolled. Call it
from a Server Component with the RebluClient the page already builds; render
the result with LessonPreview from @reblu/site-blocks. No Route Handler is
needed — nothing is fetched from the browser.
// app/formacion/[slug]/preview/[lessonSlug]/page.tsx
import { notFound } from 'next/navigation'
import { LessonPreview } from '@reblu/site-blocks'
import { getLessonPreview } from '@reblu/site-next/academy-server'
export const dynamic = 'force-dynamic'
export default async function Page({ params }) {
const { slug, lessonSlug } = await params
const lesson = await getLessonPreview(client, slug, lessonSlug)
if (!lesson) notFound()
return <LessonPreview lesson={lesson} headingLevel={1} />
}nullmeans "no preview here". The endpoint answers the same 404 for every lesson the public may not see (not a free preview, unpublished, unknown slug, draft course);getLessonPreviewmaps it tonull. Any other failure throws.- Never cached. The payload carries a short-lived signed playback token (about 2 h): the fetch is always fresh, and the page must not use ISR.
- Link to the page from
CourseSyllabuswithpreviewHrefTemplate(orgetPreviewHref) — see@reblu/site-blocks.
Shop filters
./shop-server and ./shop (REB-809) are the URL glue behind
@reblu/site-blocks's ShopFilters — see that package's README for the full
example. Two subpaths, same split reasoning as ./academy/./academy-server:
| Export | Subpath | Needs 'use client'? |
| ------ | ------- | ---------------------- |
| parseShopSearchParams(searchParams): ShopProductsParams | both | No |
| serializeShopParams(params): URLSearchParams | both | No |
| toURLSearchParams(nextSearchParams): URLSearchParams | both | No |
| buildShopFilterHrefs(current, pathname): ShopFilterHrefs | both | No |
| getShopCollection(client, params?) | both | No |
| useShopFilterLinks(): ShopFilterHrefs | ./shop only | Yes |
toURLSearchParams(searchParams) converts Next's route searchParams prop —
Record<string, string | string[] | undefined>, a repeated query key already
grouped into an array — into the URLSearchParams parseShopSearchParams
expects, preserving every repeated key as a repeated entry. Use it, not
new URLSearchParams(await searchParams): the native constructor only
accepts Record<string, string>, so handed an array value it calls
String() on it and produces ONE entry (attr[color]=red,blue) instead of
two repeated ones — silently breaking parseShopSearchParams's
repeat-count-based array/scalar distinction for every multi-value
attr[<key>] filter. See @reblu/site-blocks's README for the full
Server Component recipe built on this.
parseShopSearchParams/serializeShopParams are a deliberate MIRROR of
base-api's own parseListParams
(apps/base-api/src/lib/shop/catalogue-query.ts) — same bracket shape
(taxonomy[<key>]=<slug>, attr[<key>]=<value>, repeatable for attr), same
MVP collapse of a repeated taxonomy[<key>] to its first value. A URL this
module builds is exactly the URL the server parses back to the same params —
there is no second, drifting parser. serializeShopParams round-trips
through parseShopSearchParams for any canonical input; a single-element
attr array is the one documented exception — see params.test.ts — because
one attr[key]=value occurrence is indistinguishable from a plain scalar
once serialized (the array/scalar distinction is carried entirely by repeat
count). Reading it back with Array.isArray(...) ? value : [value] (exactly
what ShopFilters does) makes the distinction invisible to a consumer.
getShopCollection(client, params) fetches client.shop.products(params)
and client.shop.facets(params) with the identical params object, in
parallel — the seam that keeps a collection page correct. A hand-rolled page
that calls the two endpoints with separately-derived params is exactly how a
facet count and its product list drift apart.
products and facets are not equally critical: a products failure
propagates (getShopCollection rejects — there is no listing without
products), while a facets failure degrades to an empty ShopFacets so the
listing still renders, just without filter UI. That degradation is never
silent: it logs console.warn('[getShopCollection] facets fetch failed, …')
once, and the resolved ShopCollection carries facetsStatus: 'ok' |
'unavailable' so a caller that wants to act on it (retry, surface a banner)
can tell "this tenant configured no filterable axes" apart from "the facets
endpoint is down right now" without parsing a log line.
buildShopFilterHrefs(current, pathname) is pure (no next/navigation) — a
Server Component calls it directly with its own searchParams prop and
route pathname. Every returned href preserves every OTHER current param and
resets pagination (page) — a filter change is never silently stuck on page
4 of a now-smaller result set. useShopFilterLinks() is the same logic bound
reactively to usePathname()/useSearchParams(), for a filter bar that is
its own Client Component.
Cart & checkout
./cart, ./shop-route-handlers and ./shop-server's getShopOrderSummary
(REB-829) are the cart/checkout glue behind @reblu/site-blocks's
CartLineList/CartSummary/CheckoutForm/CheckoutLoginPrompt/
OrderConfirmation — see that package's README for the component props.
| Export | Subpath | Needs 'use client'? |
| ------ | ------- | ---------------------- |
| CartProvider, useCart(), useCartStore, CartItem | ./cart | Yes |
| useCartValidation(opts?), validateCart(opts?) | ./cart | Yes |
| createShopCheckoutRoute(opts?) | ./shop-route-handlers | No |
| createShopValidateCartRoute() | ./shop-route-handlers | No |
| createShopQuoteRoute() (REB-1197) | ./shop-route-handlers | No |
| createShopApplyDiscountRoute() | ./shop-route-handlers | No |
| createShopCheckEmailRoute() — deprecated (REB-834) | ./shop-route-handlers | No |
| createShopOrderRoute() | ./shop-route-handlers | No |
| getShopOrderSummary(client, sessionId) | ./shop-server | No |
| useShopOrderStatus(sessionId, opts?) | ./shop | Yes |
| useCheckoutReturn() | ./shop | Yes |
| useShopQuote(input, opts?) (REB-1197) | ./shop | Yes |
Wiring a SITE
Provider — mount
CartProvideronce near the root layout, inside a Client Component boundary (app/layout.tsxalready has one for consent/ theme providers — add it there):import { CartProvider } from '@reblu/site-next/cart' // inside the root layout's client provider tree <CartProvider>{children}</CartProvider>Route Handlers — mount the four factories, one file each (mirrors the
./route-handlersauth pattern — a one-line file per route):// app/api/shop/checkout/route.ts import { createShopCheckoutRoute } from '@reblu/site-next/shop-route-handlers' export const POST = createShopCheckoutRoute() // app/api/shop/validate-cart/route.ts export const POST = createShopValidateCartRoute() // app/api/shop/apply-discount/route.ts export const POST = createShopApplyDiscountRoute() // app/api/shop/quote/route.ts — the single-step checkout summary (REB-1197) export const POST = createShopQuoteRoute() // app/api/shop/order/[sessionId]/route.ts export const GET = createShopOrderRoute()All of them read
REBLU_API_BASE_URL/REBLU_API_KEYlazily fromprocess.env(same as./route-handlers'screateProxyRoute, which they wrap) — the API key never reaches the browser.Do not mount
createShopCheckEmailRoute()on a new SITE: it is deprecated (REB-834) and removed in the next major. Its endpoint used to say whether an email had an account — an enumeration oracle on the tenant's customer list — and now always answers{ exists: false }. An existing mount keeps working; remove it at your convenience. The checkout always offers «Iniciar sesión o continuar como invitado», and the server links a guest's order to an existing account after payment.Forward the visitor's IP. base-api rate-limits some public routes per client IP (check-email, and the auth routes such as login and magic-link), reading the IP from
x-forwarded-for. A SITE that calls/api/site/*from its own server must forward the visitor'sx-forwarded-for(andx-real-ip), or every visitor shares the SITE server's single IP window and one busy page throttles them all.createProxyRoute— and therefore everycreateShop*Routefactory — already forwards both headers; only hand-written server-side calls need to do it themselves.Pages — create
app/shop/cart/page.tsx,app/shop/checkout/page.tsxandapp/shop/order-confirmation/page.tsx. Read the cart withuseCart()from./cart, render@reblu/site-blocks'sCartLineList/CartSummary/CheckoutForm/OrderConfirmation, andfetchthe Route Handlers from step 2 for checkout/validate-cart/apply-discount/order (never call base-api directly from a Client Component — that's what step 2's Route Handlers are for). Seeapps/demo-site'sshop/{cart,checkout, order-confirmation}/page.tsxin this monorepo for a complete, working example.
Cart persistence & consent
The cart persists to localStorage under the key 'shop-cart' (exported as
CART_STORAGE_KEY), the SAME key demo-site's original hand-rolled store
used — a SITE migrating from a local copy keeps its visitors' existing
carts. This is strictly necessary storage (the visitor's own cart
contents, not tracking), so it does not need cookie-consent gating the way
@reblu/site-next/consent's analytics scripts do.
skipHydration: true on the underlying zustand persist middleware is
load-bearing: it stops the store from reading localStorage during SSR
(which would throw/desync). CartProvider calls
useCartStore.persist.rehydrate() in a useEffect — mount it once, or the
cart UI renders permanently empty.
The exact persisted shape (pinned by a test — store.test.ts, "persisted
shape (REB-837 pin)" — any future change to it MUST bump the zustand
persist version option with a migration, never edit the shape in place):
{
"state": {
"items": [
{
"productId": "p1",
"variantId": null,
"name": "Serum",
"variantName": "50ml",
"unitPrice": 1999,
"quantity": 2,
"imageUrl": "https://cdn.example.com/serum.png",
"maxStock": 10,
"taxRateBps": 2100,
"taxRateName": "IVA 21%"
}
]
},
"version": 0
}No expiry — a cart written today is read back unchanged next month. Nothing
clears it except an explicit clearCart() call — whose ONLY built-in caller is
useShopOrderStatus (below), and only when the order resolves to paid — and
a different signed-in user claiming it (REB-1276, below).
A cart claimed by a signed-in user also stores "ownerKey": "u_…" next to
items (a hash of the email, never the email). A guest cart keeps the exact
shape above, and a cart written before REB-1276 loads as a guest cart; the
version stays 0 on purpose, so an older SDK build on the same origin still
reads it.
The cart belongs to who filled it — <CartOwnerSync /> (REB-1276)
On a shared browser the cart used to survive a change of account: the next
user saw the previous one's products. Mount <CartOwnerSync /> once inside
CartProvider — one line:
'use client'
import { CartOwnerSync, CartProvider } from '@reblu/site-next/cart'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<CartProvider>
<CartOwnerSync /> {/* reads /api/auth/me (createSiteSessionRoute) */}
{children}
</CartProvider>
)
}| What happens | The cart | | ------------ | -------- | | A guest signs in | Kept, and now belongs to them (as on Shopify and Amazon) | | The owner signs out | Kept until another user signs in | | The owner signs in again | Kept | | A DIFFERENT user signs in | Cleared, then belongs to the new user |
It waits for CartProvider to read the cart back, so it never judges the
empty pre-hydration cart. sessionUrl changes the route (default
/api/auth/me). A page that already holds useSiteSession() (e.g. the
checkout prefill) can call useCartOwnerSync(session) with it instead of a
second request. Without either, the cart has no owner and behaves as before.
useCartStore is a globalThis-keyed singleton versioned by
CART_STORE_PERSIST_VERSION (REB-837 review fix): every tsup entry that
reaches for the store — ./cart directly, ./shop via useShopOrderStatus
— resolves to the exact same instance, never a disconnected copy a build
accidentally inlined. Bump CART_STORE_PERSIST_VERSION alongside a
migrate option if the persisted shape ever changes incompatibly — that
also mints a new singleton key, so a stale old-shape instance kept alive
across a dev HMR reload can never satisfy the new one. __resetCartStoreForTests()
clears the singleton slot for a test that needs to prove something about the
mechanism itself; an ordinary test should keep resetting STATE via
useCartStore.setState(...) in beforeEach instead.
The hydration gotcha (REB-837)
CartProvider rehydrates AFTER mount (the skipHydration contract above),
so on every page load with a non-empty persisted cart, itemCount jumps
0 -> N the instant hydration finishes — observed in production on the
Dermamedicin SITE as CartIcon's badge "bumping" on every single page load,
not just on a real add.
useCart() exposes hydrated: boolean for exactly this (false on the
server and on the very first client render, true once
persist.onFinishHydration fires — built with useSyncExternalStore, server
snapshot false). Pass it straight through to CartIcon's bumpOnAdd:
const { itemCount, hydrated } = useCart()
<CartIcon itemCount={itemCount} href='/shop/cart' hydrated={hydrated} bumpOnAdd />CartIcon compares itemCount AND hydrated against their own previous
values — a bump only fires when the cart was ALREADY hydrated on the
previous render and itemCount increased since. The simultaneous
false -> true / 0 -> N rehydration transition fails that first
condition; a real add afterward (hydrated already true on both sides)
passes it. A standalone useCartHydrated() is also exported for a SITE that
only needs the flag.
addItem's return value (REB-837)
addItem (both on useCartStore and via useCart()) returns
{ status: 'added' | 'clamped' | 'refused', quantity: number } — quantity
is the RESULTING line quantity (0 when refused and no line exists).
Existing callers that ignore the return value keep working unchanged.
'added'— the full requested quantity was applied.'clamped'— applied, but capped below what was requested bymaxStock(the line still GREW).'refused'— nothing was added: an invalid quantity, the product is out of stock, or the line was already atmaxStock(REB-839 — this used to come back as'clamped').quantityis still the RESULTING line quantity, which may be lower than before if fresher stock data reconciled the line down.
Use this to drive an AddToCartButton/AddedToCartToast pair's copy — see
@reblu/site-blocks's README "Add-to-cart feedback" for the full recipe.
Validating the persisted cart — useCartValidation (REB-981)
A cart persisted in localStorage outlives the catalog it was filled from:
between visits a product can be deleted, deactivated, sold out or repriced.
Never read only errors[0].message from validate-cart — it does not say
which line failed, and for a deleted product the API can only name it
«Producto desconocido». Use useCartValidation() instead, on the cart page
AND the checkout page. It runs once when the cart has hydrated (never
against the empty pre-hydration cart, never again on later cart edits) and
applies the answer of your createShopValidateCartRoute() handler to the
store:
| Validation error | What happens to the line |
| ---------------- | ------------------------ |
| not_found, inactive, out_of_stock (suggestedAction: 'remove') | removed; reported in removed with its persisted name/variantName |
| insufficient_stock (suggestedAction: 'update_quantity') | quantity clamped to newQuantity; reported in quantityChanges |
| live unitPrice differs from the persisted one | price refreshed; reported in priceChanges (from/to in cents) |
| an error matching no line (e.g. empty_cart) | nothing; reported in unresolvedErrors |
'use client'
import { useCart, useCartValidation } from '@reblu/site-next/cart'
import { CartAdjustmentsNotice, CartLineList } from '@reblu/site-blocks'
const { items, updateQuantity, removeItem } = useCart()
const validation = useCartValidation()
<CartAdjustmentsNotice
removed={validation.removed}
quantityChanges={validation.quantityChanges}
priceChanges={validation.priceChanges}
/>
<CartLineList
items={items}
onQuantityChange={updateQuantity}
onRemove={removeItem}
lineMessage={(item) => validation.getLineError(item.productId, item.variantId)?.message}
/>Before creating the checkout session, call revalidate() and only continue
on 'valid':
const outcome = await validation.revalidate()
if (outcome.status !== 'valid') return // the notice now shows what changed
// …POST /api/shop/checkout| status | Meaning |
| -------- | ------- |
| 'idle' / 'validating' | hook only: not run yet / request in flight |
| 'valid' | the server answered valid: true and nothing changed |
| 'adjusted' | the cart was changed (lines removed, quantities clamped, prices refreshed) and every error was resolved by it — show the notice and let the visitor confirm again |
| 'invalid' | problems remain that were not applied (see unresolvedErrors), or the server answered valid: false and nothing was applied |
| 'empty' | no lines; nothing was sent |
| 'failed' | the request failed (error holds the reason); the cart was left untouched |
Options (hook and function): endpoint (default '/api/shop/validate-cart'),
validate (replace the default fetch-based validator), and
removeUnavailable: false to keep unavailable lines and only flag them
through getLineError / lineErrors (status 'invalid'). The hook also
takes auto: false to skip the mount run. validateCart(opts?) is the same
operation outside React; it waits for CartProvider's hydration and never
throws. Running it again on a clean cart changes nothing.
Resolution rules (REB-998): a price_changed error counts as resolved once
its line's live price is applied — it is reported in priceChanges (so
CartAdjustmentsNotice shows it) and is not kept in lineErrors. A
price_changed with no new price to apply, or an update_quantity whose
newQuantity would not lower the line, was not applied and lands in
unresolvedErrors. The status is never 'valid' when the server said
valid: false.
useShopQuote — the pre-payment summary (REB-1197)
The single-step checkout shows the shipping, the taxes and the exact total
before the buyer pays. useShopQuote POSTs the cart, the code and the
delivery choice to your createShopQuoteRoute() handler and asks again,
debounced, whenever any of them changes by value; an answer to an older input
is dropped. null asks nothing.
'use client'
import { useCart } from '@reblu/site-next/cart'
import { useShopQuote } from '@reblu/site-next/shop'
const { items } = useCart()
const { status, quote, error, refresh } = useShopQuote(
items.length
? {
items: items.map(({ productId, variantId, quantity }) => ({ productId, variantId, quantity })),
discountCode,
fulfillment, // { type: 'SHIPPING' } | { type: 'PICKUP', pickupLocationId }
}
: null,
)
// quote.total === what Stripe charges for the same body; quote.canCheckout
// is false (with quote.blockingReason) when the order cannot be served.Options: endpoint (default /api/shop/quote), debounceMs (default
300). refresh() asks again now and resolves to the new quote (or null),
e.g. right before calling checkout; while the input is null it asks nothing
and resolves to null. error.code carries the site API code: pass null
while pickup has no point (isCheckoutDeliveryComplete in
@reblu/site-blocks) and map PICKUP_LOCATION_INVALID with
checkoutQuoteNotice to «Elige un punto de recogida» (REB-1276), so
«Reintentar» never resends a body known to fail. Render the answer with
@reblu/site-blocks's CheckoutQuoteSummary.
useShopOrderStatus — the order-confirmation flow (REB-837)
'use client'
import { useShopOrderStatus } from '@reblu/site-next/shop'
import { OrderConfirmation } from '@reblu/site-blocks'
import { useSearchParams } from 'next/navigation'
export default function OrderConfirmationPage() {
const sessionId = useSearchParams().get('session_id')
const { status, order, recheck } = useShopOrderStatus(sessionId)
return (
<OrderConfirmation
status={status}
order={order}
sessionId={sessionId ?? undefined}
cartHref='/shop/cart'
onRecheck={recheck}
/>
)
}Polls GET /api/shop/order/{sessionId} — the SITE's OWN Route Handler built
from createShopOrderRoute, never base-api directly — via fetch with an
AbortController, for a configurable window (timeoutMs, default 30000ms)
with backoff (intervalMs × backoffFactor per attempt, capped at
maxIntervalMs). Returns one of loading | paid | pending_timeout | failed |
refunded | partially_refunded | not_found | error, mapped from
ShopOrder.paymentStatus (PENDING|PAID|FAILED|REFUNDED|PARTIALLY_REFUNDED)
and the route's own 404. A 404 is NOT terminal while the window is open:
base-api creates the Order row in the checkout.session.completed webhook
(at /checkout time only a PendingCheckout exists), so a customer back from
Stripe before the webhook lands sees 404 for a real session — the hook keeps
polling and resolves not_found only if the window ends on a 404 (REB-840).
OrderConfirmation accepts every one of these
statuses directly — loading/paid are drop-in aliases for its original
polling/found states, so existing callers on the old 3-state API are
unaffected.
Clears the cart (@reblu/site-next/cart's store, imperatively — not
useCart(), since this runs outside a component body) ONLY on paid.
recheck() restarts the poll loop from loading — wire it to
OrderConfirmation's onRecheck, which renders it as the "Volver a
comprobar" button on pending_timeout/error.
Pass buildUrl to point at a non-default order route path; everything else
(timeoutMs/intervalMs/backoffFactor/maxIntervalMs/requestTimeoutMs)
tunes the polling window. Checkout is card-only today, so a PENDING order
past the window is expected to be transient — see REB-836 for the
redirect-origin hardening this flow sits behind.
timeoutMs (default 30000ms) bounds the WHOLE polling window via its own
independent timer — it fires pending_timeout even if a single request
hangs forever, never relying on that request ever settling. requestTimeoutMs
(default 8000ms) is the separate PER-REQUEST timeout: one slow/hung request
past it is aborted and retried with backoff (transient, not fatal); only the
window elapsing is terminal.
Standard success/cancel URLs (REB-837)
createShopCheckoutRoute builds successUrl/cancelUrl for you when the
posted body doesn't already include them — an EXPLICIT value on the body
always wins, so a SITE that already builds its own is completely unaffected:
// app/api/shop/checkout/route.ts — defaults to /cart?pago=ok&session_id={CHECKOUT_SESSION_ID}
// and /cart?pago=cancelado
export const POST = createShopCheckoutRoute()
// Custom cart path — reshapes BOTH defaults:
export const POST = createShopCheckoutRoute({ cartPath: '/tienda/carrito' })
// Fully custom paths:
export const POST = createShopCheckoutRoute({
successPath: '/checkout/gracias?session_id={CHECKOUT_SESSION_ID}',
cancelPath: '/checkout/cancelado',
})Built absolute from the REQUEST's own origin (request.nextUrl.origin) —
never a caller-supplied value, so the built URL can never point off-SITE.
The {CHECKOUT_SESSION_ID} placeholder is preserved verbatim; Stripe
substitutes it at redirect time.
useCheckoutReturn() (client hook, ./shop) parses the return query on the
cart page:
'use client'
import { useCheckoutReturn } from '@reblu/site-next/shop'
import { CartSummary } from '@reblu/site-blocks'
function CartPage() {
const { status } = useCheckoutReturn() // 'ok' | 'cancelled' | null
// ...
return <CartSummary {...summaryProps} notice={status === 'cancelled'} />
}CartSummary/CartLineList's notice prop (@reblu/site-blocks) renders
the default «Pago cancelado: tu carrito sigue aquí» copy when true, or any
custom ReactNode — see that package's README.
Signed-in checkout — prefill and return to checkout (REB-1118)
A visitor who is already signed in (a customer or a team member) should not retype their details, and a login started from checkout should land back on checkout.
Session route. One line, server-only. It forwards only the visitor's
sb-*auth cookies to base-apiGET /api/site/auth/me. base-api validates the session with the tenant's Supabase Auth and answers{ email, name, phone, role }or 401. The session token is never put in a response and noSet-Cookieis relayed.// app/api/auth/me/route.ts import { createSiteSessionRoute } from '@reblu/site-next/route-handlers' export const GET = createSiteSessionRoute()A Server Component can ask directly instead:
await getSiteSession((await headers()).get('cookie'))(same entry).Prefill the checkout form with
useSiteSession()from@reblu/site-next/auth. It returns{ status: 'loading' | 'anonymous' | 'authenticated', user }. Any failure reads as anonymous, so checkout stays a guest checkout:'use client' import { useSiteSession } from '@reblu/site-next/auth' const session = useSiteSession() // reads /api/auth/me const defaults = session.status === 'authenticated' ? { customerEmail: session.user.email, customerName: session.user.name ?? '', customerPhone: session.user.phone ?? '', } : undefined // Hide «¿Ya tienes cuenta? Inicia sesión» when status === 'authenticated'.Return to checkout after login. Link to the login page with the checkout path and pass it to
LoginFormasreturnTo:// checkout: <a href={`/login?returnTo=${encodeURIComponent('/shop/checkout')}`}>Inicia sesión</a> <LoginForm {...urls} returnTo={searchParams.returnTo} />returnTotravels with the password login and the MFA step. base-api only honours a URL on the tenant's own public site, so it is never an open redirect. Anything else falls back to the role's usual destination. Magic-link logins do not carry it yet.
Staff-only («solo personal») discount codes. A code marked «Solo
personal» in nexus only applies when the buyer's session, validated on the
server, belongs to an active team member, and the checkout email is that
member's own. A typed email never counts. createShopCheckoutRoute() and
createShopApplyDiscountRoute() forward the sb-* auth cookies for this by
default, so no SITE change is needed. Otherwise the code is refused with
«Este código es solo para el personal. Inicia sesión con tu cuenta del equipo
para usarlo.» (DISCOUNT_INVALID on checkout). Pass
{ forwardAuthCookies: false } to opt out; staff codes are then always
refused.
Automatic staff discount (REB-1131). With a staff session, base-api
also applies the active «Solo personal» code that discounts the most, with
no code typed. Discounts never stack: a typed code is kept only when it is
larger. The checkout recomputes it on the server. createShopValidateCartRoute()
forwards the sb-* cookies for this by default (opt out with
{ forwardAuthCookies: false }), and the result arrives as autoDiscount:
{ code, label, amount } on validate-cart, apply-discount and checkout:
import { validateCart } from '@reblu/site-next/cart'
const outcome = await validateCart()
if (outcome.autoDiscount) {
// «Descuento de personal aplicado» — pass it to the site-blocks
// CartSummary / CheckoutForm as `autoDiscount`.
}Buyers without a staff session get no autoDiscount and today's responses.
An expired session reads as anonymous. base-api never refreshes a session from a SITE request, because a refresh rotates the refresh token and a Server Component cannot write the new cookie back. Visiting nexus or the portal, or signing in again, refreshes it.
Cookie consent
./consent (REB-642) — <ConsentProvider>/useConsent(), <CookieBanner> +
<CookiePreferencesTrigger>, <ConsentGate>, and <ConsentedAnalytics>. Mount
<ConsentProvider> once at the layout root and <CookieBanner> once inside it:
import { ConsentProvider, CookieBanner } from '@reblu/site-next/consent'
import { cookiePolicyVersion } from '@reblu/site-next/consent-server'
<ConsentProvider policyVersion={cookiePolicyVersion(legal?.cookiePolicy ?? null)}>
{children}
<CookieBanner />
</ConsentProvider>Tenant-editable copy — cookieBannerToConsentTexts (REB-845)
<CookieBanner> renders the SDK's own Spanish defaults unless you pass
texts (a Partial<ConsentBannerTexts>). Map the /api/site/analytics
response's cookieBanner field onto texts with cookieBannerToConsentTexts
instead of hand-rolling the mapping — it already drops null AND
blank/whitespace-only fields, so an unfilled nexus field falls back to the
default instead of rendering an empty title or an unlabelled button.
Import cookieBannerToConsentTexts from @reblu/site-next/consent-server,
not from ./consent, when you call it from a Server Component — the root
layout that mounts <ConsentProvider>/<CookieBanner> is one (REB-846).
./consent carries its own leading 'use client' directive, and with this
package's splitting: false build that directive tags the WHOLE entry, so
calling any of its exports — even a pure one with no React — from a Server
Component throws Attempted to call cookieBannerToConsentTexts() from the
server but cookieBannerToConsentTexts is on the client. cookieBannerToConsentTexts
itself has no React/browser dependency, so ./consent-server (directive-free)
re-exports the same function for exactly this call site; <CookieBanner> the
component still comes from ./consent as usual — importing a Client
Component into a Server Component's JSX is fine, it is only the function
call that must happen off the client-tagged module:
import { CookieBanner } from '@reblu/site-next/consent'
import { cookieBannerToConsentTexts } from '@reblu/site-next/consent-server'
<CookieBanner texts={cookieBannerToConsentTexts(analytics?.cookieBanner)} />DEFAULT_CONSENT_BANNER_TEXTS, mergeConsentBannerTexts and the
ConsentBannerTexts type are available from both ./consent and
./consent-server for the same reason — use whichever subpath matches where
the call happens.
Even if you build texts yourself, <CookieBanner> treats a blank or
whitespace-only value for any key the same as undefined — it always falls
back to that key's default rather than rendering empty. There is no need to
?.trim() a value before passing it.
Team photos — <RebluPhoto> (REB-1102)
A team page's coverImageUrl is a stable public URL of fixed-size WebP
copies (…/site-media/team/{pageId}/{version}-{160|480|960}.webp, see
docs/integrations/team-photos.md). <RebluPhoto> renders it with
next/image and a custom loader, so the browser gets a responsive srcset of
those copies and Vercel's image optimizer is never used (no optimizer
cost, no images.remotePatterns entry needed).
import { RebluPhoto } from '@reblu/site-next/photo'
const { data: team } = await client.pages.list({ type: 'team' })
{team.map((member) =>
member.coverImageUrl ? (
<div key={member.slug} className='relative aspect-[3/4]'>
<RebluPhoto src={member.coverImageUrl} alt={member.title} fill className='object-cover' />
</div>
) : null,
)}- It takes every
next/imageprop exceptloaderandunoptimized;altis required (''for a decorative photo). Give itwidth/heightorfill. sizesdefaults to(min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw(REBLU_PHOTO_DEFAULT_SIZES), a team grid. Pass your own for other layouts; a small avatar (sizes='64px') gets the160variant.- A
srcthat is not a Reblu team-photo URL (a legacy cover, an external image, a localhttp://dev stack) rendersunoptimized, exactly as given. - In development only,
next/imagemay log "has a "loader" property that does not implement width" when thewidthprop maps to the same variant assrc(for examplewidth={960}on a-960.webpURL). Thesrcsetis still correct; the warning is harmless. ./photocarries'use client':next/imageneeds the loader function, and only a client component can pass one. Use it from a Server Component like any other client component.
The loader on its own lives in @reblu/site-next/photo-loader (pure, no
imports, server-safe): rebluPhotoLoader({ src, width }) returns the smallest
variant >= width (capped at 960) for a Reblu team-photo URL and any other
src unchanged; isRebluTeamPhotoUrl(src) is the strict match it uses
(https only, no query string or fragment).
Page titles — buildMetadata (REB-871)
@reblu/site-next/seo (dependency-free, no 'use client') holds the one
page-title rule the Reblu SITE API also applies server-side:
- an entity SEO title (
seoTitle/metaTitle) that is set is the whole title — used as-is, the site name is never appended; - otherwise the tenant template (
config.seoTitleTemplate, default%s | {siteName}) is applied once to the display title — or to the section label on a list/index route; - the brand is never duplicated.
Detail endpoints already return seo.title resolved, so a detail route is a
drop-in:
// app/servicios/[slug]/page.tsx
import type { Metadata } from 'next'
import { buildMetadata } from '@reblu/site-next/seo'
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params
const [page, config] = await Promise.all([reblu.pages.bySlug(slug), reblu.config.get()])
return buildMetadata({ seo: page.seo, title: page.title, seoTitle: page.seoTitle, config })
}
// app/blog/page.tsx — list/index route: the template with the section label
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ sectionLabel: 'Blog', config: await reblu.config.get() })
}buildMetadata emits title: { absolute }, so a title.template in the root
layout can never append the brand a second time — drop any hand-written
`${title} · Brand` from your routes. Passing title/seoTitle alongside
seo keeps the route correct against an older API that does not send seo
yet. Need the string only? resolveSeoTitle({ seoTitle, title, sectionLabel },
{ template: config.seoTitleTemplate, siteName: config.siteName }).
Description/Open Graph are intentionally minimal here (REB-823 extends them).
Home page and canonical URL — buildMetadata({ config, home: true }) (REB-651)
The site has its own search title and description in nexus (Ajustes → SEO y
Redes), separate from siteName/siteDescription, which stay the identity
used in breadcrumbs and chrome. GET /api/site/config serves them as
seoTitle, seoDescription and canonicalUrl (all null when unset).
// app/page.tsx — the home page: no hardcoded title, description or domain
import type { Metadata } from 'next'
import { buildMetadata } from '@reblu/site-next/seo'
export async function generateMetadata(): Promise<Metadata> {
return buildMetadata({ config: await reblu.config.get(), home: true })
}
// app/layout.tsx — every route resolves relative Open Graph paths against it
import { normalizeCanonicalUrl } from '@reblu/site-next/seo'
export async function generateMetadata(): Promise<Metadata> {
const config = await reblu.config.get()
const siteUrl = normalizeCanonicalUrl(config.canonicalUrl) ?? process.env.NEXT_PUBLIC_SITE_URL!
return { metadataBase: new URL(siteUrl) /* , title, openGraph… */ }
}config.seoTitle, when set, is the whole home title — used as-is, never suffixed by the template. Empty →siteName, exactly as before.config.seoDescriptionfalls back tositeDescription.- A valid
config.canonicalUrl(anhttpsorigin) becomesmetadataBaseplusalternates.canonical: '/', so anogImageUrlgiven as a relative path resolves against the site's domain. The env var becomes a fallback.
home: true is explicit: a route whose title happens to be empty never picks
up the site's SEO title. Need the strings only? resolveSiteSeoTitle(config)
and resolveSiteSeoDescription(config).
Structured data (JSON-LD)
@reblu/site-next/json-ld (REB-622, server-only, dependency-free) renders one
application/ld+json <script> tag the safe way:
import { JsonLd } from '@reblu/site-next/json-ld'
<JsonLd id='page' data={page.jsonLd} />A <script> body is HTML's raw-text context: the parser ends it on the
first </script byte sequence it sees, no matter whether that sequence sits
inside a JSON string. JSON.stringify(data) alone leaves < verbatim, so
tenant-authored data serialised straight into a JSON-LD <script> is a
stored-XSS sink — a title containing </script><img src=x onerror=alert(1)>
breaks out of the element and runs. jsonLdScriptContent(value), which
<JsonLd> wraps, escapes <, >, &, U+2028 and U+2029 to their \uXXXX
JSON form: every escape is a standard JSON character escape, so
JSON.parse(jsonLdScriptContent(value)) always recovers value unchanged.
This is an escaper, not the strip/allowlist sanitiser
sanitizeHtml in @reblu/site-blocks
covers — that one guards HTML rendered via dangerouslySetInnerHTML; this one
guards the JSON-LD script boundary, a different sink sanitizeHtml was never
