What you can do
Rise & Crumb is a bakery with a Next.js site. Its marketing team built a Weekend Special banner in RuleCMS: a photo, a headline, a few lines about this weekend's bake, and a Pre-order now Button.
The developer wants four things from that banner:
- In the first HTML. The banner arrives with the page, so it paints fast and search engines can read it.
- A button that runs the site's code. Pre-order now opens the pre-order drawer that already lives in the app.
- Edits without a deploy. When marketing publishes a change, the live site shows it within about a minute.
- A private token. The RuleCMS token stays on the server and never reaches a visitor's browser.
Pre-fetched mode gives you all four. Your server fetches the published widget with fetchRuleCMSWidget. You hand that data to RuleCMSWidget with mode="pre-fetched", and the widget renders from it.
If the widget has nothing to click, you may not need this page. On the Next.js App Router, RuleCMSWidgetServer renders a widget on the server with less code. The table under How this fits at the end of this page compares the options.
How it thinks
There are two pieces, and each has one job.
fetchRuleCMSWidgetruns only on your server. It uses your token to request the widget and returns plain data: the rows, the components, and the styles they need. Nothing about it ships to the browser, including the token.RuleCMSWidgetrenders that data. In pre-fetched mode it never fetches anything itself. It builds the widget from the data you pass asinitialData.
Between the two sits one rule: only data travels from your server code to your browser code. The widget data can make that trip. A function cannot. So the click handler is created inside your own client component, next to RuleCMSWidget, and handed to it through componentProps — the same map Custom Props and Click Handlers teaches, keyed by the button's Component id.
Here is the case that surprises people. A developer writes the handler in the page file, right next to the fetch, and passes it down. Next.js stops with an error, because the page is server code and a function cannot leave the server. Move the handler into the client component and it works.
Does it render on the server and in the browser?
Yes. The same widget renders twice, and both renders matter.
- On your server. This render produces the HTML your visitors see first. The photo, the headline, and a fully styled Pre-order now button are all in it.
- In the browser. Once the page loads its JavaScript, React renders the widget again from the same data and connects your click handlers to the HTML that is already on the page.
Both renders produce the same markup, so nothing flashes, jumps, or redraws when the page comes alive. The only difference a visitor notices is that the button starts responding.
RuleCMS tests widget rendering on the server and in the browser, so the HTML your visitors see first is the finished widget, not a placeholder.
In the short moment between the HTML arriving and the JavaScript loading, the button looks ready but does nothing yet. A button with a Link set in the composer simply follows that link, like any other link on the page.
Render the Weekend Special banner on the server
This walkthrough uses the Next.js App Router. The Pages Router version comes later; it reuses everything except the fetch.
Step 0: In RuleCMS
Nothing to code yet. The banner is published to Staging or Production. Collect three values from there.
- Open the organization → team → project → environment where the banner is published.
- Open the banner — the published widget, not the composer — and open the Integration tab.
- Copy the PublishedKey. It looks like
{environmentId}---widget-…. - Copy an enabled client token for that same environment. It must not start with
dev.. Tokens also live on Projects, Environments & Tokens. - Open the banner in the composer and click the Pre-order now Button. At the top of the Modify drawer, open the Component id accordion and click Copy.
Pre-fetched mode works with a Development draft too: a dev. token and the draft widget-… key from Development Integration, fetched fresh on every request.
Step 1: Install and configure
npm install @rulecms/widget-react @rulecms/source-components-react
# .env.local RULECMS_TOKEN=your-production-token RULECMS_WEEKEND_BANNER_KEY=<environment id>---widget-<widget id>
The token has no NEXT_PUBLIC_ prefix, so Next.js never sends it to the browser. In pre-fetched mode the browser never needs it.
Step 2: Register the component library
The ten built-in components — Text, Image, Button, and the rest — ship in @rulecms/source-components-react. Register that library once, with a static import.
// lib/rulecms-libraries.ts
import * as sourceComponents from '@rulecms/source-components-react';
import type { LibraryRegistrationMap } from '@rulecms/widget-react';
// Must be a static import, not () => import(...): pre-fetched widgets render
// during the server render, which cannot wait for a lazy import.
export const rulecmsLibraries: LibraryRegistrationMap = {
default: sourceComponents,
};If your widgets also use your team's own library, add it to the same map. Build and enable covers that.
Step 3: Fetch the widget on the server
// lib/get-weekend-banner.ts
import {
fetchRuleCMSWidget,
FetchRuleCMSWidgetError,
type RuleCMSWidgetData,
} from '@rulecms/widget-react/server';
export async function getWeekendBanner(): Promise<RuleCMSWidgetData | null> {
try {
return await fetchRuleCMSWidget({
publishedKey: process.env.RULECMS_WEEKEND_BANNER_KEY!,
token: process.env.RULECMS_TOKEN!,
fetchOptions: { next: { revalidate: 60 } },
});
} catch (error) {
// A CMS outage should cost the banner, not the whole page.
const status = error instanceof FetchRuleCMSWidgetError ? error.status : undefined;
console.error('Weekend banner fetch failed', { status, error });
return null;
}
}Sixty seconds is already the default for Staging and Production tokens. It is written out here so you can see it, and Widget Caching explains the window and how to change it.
When RuleCMS answers with an error, fetchRuleCMSWidget throws a FetchRuleCMSWidgetError that carries the HTTP status. Returning null lets the page render without the banner instead of failing.
Step 4: The page, a Server Component
// app/page.tsx
import { getWeekendBanner } from '@/lib/get-weekend-banner';
import { WeekendBanner } from '@/components/WeekendBanner';
export default async function HomePage() {
const banner = await getWeekendBanner();
return (
<main>
{banner && (
<WeekendBanner
publishedKey={process.env.RULECMS_WEEKEND_BANNER_KEY!}
initialData={banner}
/>
)}
{/* ...rest of the homepage... */}
</main>
);
}Only data crosses into the client component here: the widget data and its key.
Step 5: The client component that adds the click handler
usePreorderDrawer stands in for the bakery site's own drawer hook.
// components/WeekendBanner.tsx
'use client';
import { useMemo } from 'react';
import { RuleCMSWidget } from '@rulecms/widget-react';
import type { RuleCMSWidgetData } from '@rulecms/widget-react/server';
import { rulecmsLibraries } from '@/lib/rulecms-libraries';
import { usePreorderDrawer } from '@/components/preorder-drawer';
// Must match the button's Component id in RuleCMS; re-creating the button changes it.
const PREORDER_BUTTON_ID = 'b721c4e2-8f0a-4c31-9a77-1d5e3f0b2c44';
export function WeekendBanner({
publishedKey,
initialData,
}: {
publishedKey: string;
initialData: RuleCMSWidgetData;
}) {
const { openDrawer } = usePreorderDrawer();
const componentProps = useMemo(
() => ({
[PREORDER_BUTTON_ID]: { onClick: () => openDrawer('weekend-special') },
}),
[openDrawer]
);
return (
<RuleCMSWidget
mode="pre-fetched"
publishedKey={publishedKey}
initialData={initialData}
libraries={rulecmsLibraries}
componentProps={componentProps}
/>
);
}useMemo keeps the same componentProps object between renders, so the widget does not re-render every time its parent does.
That is a safe stop. Run the site, load the homepage, and click Pre-order now. The drawer opens, and the button still looks the way marketing styled it.
Step 6: What happens when someone visits
getWeekendBannerreturns the widget data. Next.js asks RuleCMS for a fresh copy at most every 60 seconds.- Next.js renders
WeekendBanneron the server. The HTML includes the banner and a fully styled button. - The browser paints that HTML right away. When the JavaScript loads, the widget renders again from the same data, and the Pre-order now button is connected to
openDrawer. - A click opens the drawer. The browser never calls RuleCMS, and the token never leaves the server.
To confirm the banner is in the server HTML, run next build && next start, then fetch the page without a browser from a second terminal:
curl -s http://localhost:3000 | grep -o 'data-rulecms-button[^>]*>[^<]*' # data-rulecms-button="" data-rulecms-button-variant="solid" data-rulecms-button-tone="brand">Pre-order now
Other setups
Next.js Pages Router
The client component from Step 5 works unchanged. Only the fetch moves, into getStaticProps.
// pages/index.tsx
import type { GetStaticProps } from 'next';
import { fetchRuleCMSWidget, type RuleCMSWidgetData } from '@rulecms/widget-react/server';
import { WeekendBanner } from '@/components/WeekendBanner';
type HomeProps = { banner: RuleCMSWidgetData; publishedKey: string };
export const getStaticProps: GetStaticProps<HomeProps> = async () => {
const publishedKey = process.env.RULECMS_WEEKEND_BANNER_KEY!;
const banner = await fetchRuleCMSWidget({
publishedKey,
token: process.env.RULECMS_TOKEN!,
});
return { props: { banner, publishedKey }, revalidate: 60 };
};
export default function Home({ banner, publishedKey }: HomeProps) {
return <WeekendBanner publishedKey={publishedKey} initialData={banner} />;
}Here the revalidate on getStaticProps decides how fresh the page is, and the 'use client' line in the banner component is simply ignored. Remix works the same way, with the fetch inside a loader.
Let a ruleset choose the banner
A widget selection ruleset can pick a different banner for each visitor — one for loyalty members, another for guests. plan comes from your own session code.
const fallbackKey = process.env.RULECMS_WEEKEND_BANNER_KEY!;
const banner = await fetchRuleCMSWidget({
rulesetPublishedKey: process.env.RULECMS_BANNER_RULESET_KEY!,
params: { locale: 'en-US', path: '/', user: { plan } },
fallbackPublishedKey: fallbackKey,
token: process.env.RULECMS_TOKEN!,
});
const publishedKey = banner.selection?.widgetPublishedKey ?? fallbackKey;Three things change compared with a single widget:
- No caching. A ruleset lookup is a POST, which Next.js does not cache, so it runs on every render unless you cache the result yourself.
- The fallback. If the lookup fails with a server error or a network problem, you get the fallback widget, and it arrives without
selection. That is why the last line falls back tofallbackKey. - One Component id per widget. Each banner the ruleset can pick has its own Pre-order now button with its own Component id. Add every one of them to
componentProps. Outside production, the browser console names the ids the chosen banner does not contain; here that is expected.
The parameters you can send, and how rules match them, live on Resolve from your app.
Things that trip people up
- The token in the browser. Keep it in a server-only environment variable, with no
NEXT_PUBLIC_prefix. Pre-fetched mode never needs it in the browser. - A lazy library import. Register the component library with a static import. With
() => import(...), the server may render nothing for the widget. - A handler written in the page file. Build
componentPropsinside the client component. A function cannot be passed down from server code. - A re-created button. The Component id belongs to that one button. If marketing deletes the button and adds a new one, the id changes and the click silently stops working. Outside production, the browser console names any id that matches nothing.
- The first moment. Until the JavaScript loads, the button does nothing, and a button with a Link just follows it.
What to read next
- Click Handlers on Server-Rendered Widgets — The same Pre-order button on
RuleCMSWidgetServer, plus one handler for every product card. The page sends almost no widget JavaScript. - Custom Props and Click Handlers — Everything about
componentProps: the Component id, a button inside a repeated collection, a Button that also has a Link, and a disabled Button. - Widget Caching — The 60-second window behind Step 3, and how to shorten it, lengthen it, or clear it on demand.
- Mount Your Own Components — Put a component from your app inside the banner, such as a pre-order form. It works in pre-fetched mode too.
- Widget Selection Rulesets — Decide in the dashboard which banner each visitor sees.
How this fits
Pre-fetched mode is one of three ways to put a widget on a React page. They differ in what visitors get first and whether a component can run your code.
| Setup | What visitors get first | Click handlers | When you reach for it |
|---|---|---|---|
RuleCMSWidget, the default | No widget content until the browser fetches it | Yes | A page that only renders in the browser, or a Development preview on your local site. |
fetchRuleCMSWidget plus RuleCMSWidget in pre-fetched mode | The finished widget | Yes, once the JavaScript loads | The widget belongs in the first HTML and still has to run your code. This page. |
RuleCMSWidgetServer | The finished widget, with no widget JavaScript | Yes, with RuleCMSButtonClicks | A Next.js App Router page. The widget is in the first HTML, and a small client wrapper runs your handlers — one button, or every button on the page. |
RuleCMSWidgetServer can still show a component from your app through a Custom slot, as Mount Your Own Components explains. It cannot take componentProps. Click handlers on its buttons come from a client wrapper instead. Click Handlers on Server-Rendered Widgets walks through the drawer, a video on every product card, and a click counter.
Pre-fetched mode changes nothing for marketing. They compose and publish the banner exactly as before, and every publish reaches the site within the caching window. The Component id stays the same when they restyle the button, and when the widget moves from Development to Staging and Production.