npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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:

  1. NextCacheAdapter — implements @reblu/site-client's RebluCacheAdapter, translating a cache policy into Next ISR fetch options.
  2. Editable-CTA glue — the EditableCta async Server Component, its CtaButtons 'use client' boundary, and StickyMobileCta.
  3. 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/*.
  4. <JsonLd> (./json-ld) — safe application/ld+json <script> rendering; see "Structured data (JSON-LD)" below.
  5. 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.
  6. Academy (./academy, ./academy-server) — AcademyEnrollmentForm, the anonymous enrollment-request form, plus deriveValidationFields to build its per-tenant validation fields from course/config data, and getLessonPreview (REB-1135), the server fetcher for a public free-preview lesson. See "Academy" below.
  7. Shop filters (./shop, ./shop-server) — URL ↔ ShopProductsParams glue for @reblu/site-blocks's ShopFilters: 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-themes

Peer 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.

  1. Tag every cached read with reblu:content, plus a safety time-based revalidate window (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 } stays no-store; keep that for what must be live per visitor (session, cart, previews). The SDK already reads blog and lesson previews and shop.order that way.

    What the window guarantees (REB-1245). The adapter caches each read's outcome, a 404 included. The base-api call runs no-store inside Next's unstable_cache, under the reblu:content tag and the revalidate window. 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. Why unstable_cache and not 'use cache' + cacheTag + cacheLife: 'use cache' only works when the SITE turns on cacheComponents in next.config, which changes how every route renders, and it needs Next's compiler to transform this package's code. unstable_cache works in any Next 16 app, and it uses the same incremental cache and tag manifest as 'use cache', so revalidateTag(tag, { expire: 0 }) and revalidateTag(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 plain fetch. Under next dev, normal navigation caches the same way, but a request that sends Cache-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.

  2. 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_SECRET to 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 with x-reblu-timestamp and x-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 kind or tags that are not reblu:*;
    • otherwise revalidates each tag by kind: unpublish/delete call revalidateTag(tag, { expire: 0 }), so a removed page returns 404 on the next request; publish/update call revalidateTag(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. LoginForm dispatches the reblu:magic-link-requested DOM event on window; AuthCallback listens 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. emitMagicLinkRequested and onMagicLinkRequested are 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_SECONDS with a visible countdown, then offers "Pedir otro enlace". The interval is the Supabase project's setting, not ours — MAGIC_LINK_COOLDOWN_SECONDS mirrors it in one place, and a SITE whose project uses a different value passes cooldownSeconds.

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 after onSubmitted with '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-in requested, 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.
  • nextSteps adds an ordered «Próximos pasos» list under the success message, only for 'received'; an enrolled or approved-to-pay visitor never sees it. true uses the generic defaults (DEFAULT_ACADEMY_ENROLLMENT_NEXT_STEPS: «Solicitud recibida» → «Revisión» (current) → «Pago y acceso»). An object overrides any of heading, steps ({ title, description? }[]) and currentStep (zero-based; steps before it are done, it carries aria-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, AcademyCourseDetail exposes only requiresValidation + validationFieldLabel (a single generic field). deriveValidationFields falls 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 — AcademyValidationSource is 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; AcademyEnrollmentForm never hardcodes a field name — it only ever iterates the validationFields array 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} />
}
  • null means "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); getLessonPreview maps it to null. 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 CourseSyllabus with previewHrefTemplate (or getPreviewHref) — 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

  1. Provider — mount CartProvider once near the root layout, inside a Client Component boundary (app/layout.tsx already 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>
  2. Route Handlers — mount the four factories, one file each (mirrors the ./route-handlers auth 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_KEY lazily from process.env (same as ./route-handlers's createProxyRoute, 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's x-forwarded-for (and x-real-ip), or every visitor shares the SITE server's single IP window and one busy page throttles them all. createProxyRoute — and therefore every createShop*Route factory — already forwards both headers; only hand-written server-side calls need to do it themselves.

  3. Pages — create app/shop/cart/page.tsx, app/shop/checkout/page.tsx and app/shop/order-confirmation/page.tsx. Read the cart with useCart() from ./cart, render @reblu/site-blocks's CartLineList/CartSummary/ CheckoutForm/OrderConfirmation, and fetch the 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). See apps/demo-site's shop/{cart,checkout, order-confirmation}/page.tsx in 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 by maxStock (the line still GREW).
  • 'refused' — nothing was added: an invalid quantity, the product is out of stock, or the line was already at maxStock (REB-839 — this used to come back as 'clamped'). quantity is 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.

  1. Session route. One line, server-only. It forwards only the visitor's sb-* auth cookies to base-api GET /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 no Set-Cookie is 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).

  2. 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'.
  3. Return to checkout after login. Link to the login page with the checkout path and pass it to LoginForm as returnTo:

    // checkout: <a href={`/login?returnTo=${encodeURIComponent('/shop/checkout')}`}>Inicia sesión</a>
    <LoginForm {...urls} returnTo={searchParams.returnTo} />

    returnTo travels 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/image prop except loader and unoptimized; alt is required ('' for a decorative photo). Give it width/height or fill.
  • sizes defaults 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 the 160 variant.
  • A src that is not a Reblu team-photo URL (a legacy cover, an external image, a local http:// dev stack) renders unoptimized, exactly as given.
  • In development only, next/image may log "has a "loader" property that does not implement width" when the width prop maps to the same variant as src (for example width={960} on a -960.webp URL). The srcset is still correct; the warning is harmless.
  • ./photo carries 'use client': next/image needs 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.seoDescription falls back to siteDescription.
  • A valid config.canonicalUrl (an https origin) becomes metadataBase plus alternates.canonical: '/', so an ogImageUrl given 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