Contents

Click Handlers on Server-Rendered Widgets

Rise & Crumb's marketing team builds banners and product cards in RuleCMS. The developer wants those widgets in the HTML the server sends, with almost no widget JavaScript on the page, and still wants the buttons to run the bakery's own code. One button opens the pre-order drawer. Every product card's Watch the bake button plays that product's video in the site's player. Another button counts the click and then follows its link. RuleCMSButtonClicks is the wrapper that does this around a RuleCMSWidgetServer widget.

What you can do

Rise & Crumb is a bakery with a Next.js App Router site. Marketing builds the banners and the product cards in RuleCMS. The developer wants those widgets in the HTML the server sends, with no widget JavaScript on the page, and still wants the buttons to run the bakery's own code.

Four jobs come up in the first week:

  • Open the pre-order drawer. The Weekend Special banner has one Pre-order now Button. A click opens the drawer the site already has. Nothing in RuleCMS knows that drawer exists.
  • Play a video in the site's player. Every product card has a Watch the bake Button whose Link is that product's video. One handler covers every card. It reads the link, stops the browser from leaving the page, and opens the bakery's video popup.
  • Count the click and still follow the link. The Our story Button links to the about page. The handler records the click, then lets the browser navigate.
  • One special button among many. On a page of cards, Pre-order now opens the drawer, and every other video button opens the player. The specific button wins. The rest share one handler.

RuleCMSButtonClicks is the wrapper that does this. You put it around a RuleCMSWidgetServer widget, in a small client component of your own. The widget still renders entirely on the server. Your handlers are created in the browser, next to the wrapper, so they can use your React state the way any other click handler can.

You need @rulecms/widget-react 23.1.0 or later. Addressing one button by its Component id also needs @rulecms/source-components-react 20.22.0 or later, which is what writes that id onto the button. A handler for every button, the video case, works with older buttons too.

How it thinks

Two pieces, and they never trade a function.

  • RuleCMSWidgetServer stays a Server Component. It fetches the widget and writes the finished HTML, including a real, styled button. It still cannot take componentProps. A function cannot leave the server.
  • RuleCMSButtonClicks is your client wrapper. It renders no box of its own, so the layout does not change. After the page loads its JavaScript, a click on a RuleCMS button reaches the wrapper, and the wrapper calls the handler you gave it.

Import the wrapper from @rulecms/widget-react/button-clicks, not from @rulecms/widget-react. The button-clicks entry is its own small file, about 0.4 KB gzip. Importing anything from the main entry pulls in the whole client package, about 18 KB gzip, which is the weight this page exists to avoid.

Enter and Space on a button, and Enter on a link, count as a click. The wrapper sees them the same way it sees a mouse click.

The wrapper only notices buttons RuleCMS rendered. A button your page wrote itself, sitting next to the widget, is left alone.

When you reach for which handler

The wrapper takes two optional props. Most pages need only one of them.

PropWhat it coversWhen you reach for it
handlersOne button, named by its Component id from the Modify drawer. The same id Custom Props and Click Handlers uses.The banner has one Pre-order now button, and you know which widget it lives in.
forEveryButtonEvery RuleCMS button inside the wrapper. The handler is told which one was clicked.One widget per product, each with a Watch the bake button. You do not want a list of ids that breaks every time marketing adds a card.

When both are set, a button named in handlers uses that handler and skips forEveryButton. Everything else falls through to forEveryButton. A click that matches neither does what the button already did: a linked button navigates, a plain button does nothing.

Open the pre-order drawer

This is the banner from Server Rendering with Pre-fetched Mode, done with the server component instead. Marketing has published the Weekend Special widget. In the composer, select Pre-order now and copy its Component id from the Modify drawer. Leave Link empty. A plain button has nowhere to go until your handler exists, which is what you want for a drawer.

The page

The page is a Server Component. It renders the widget and wraps it. The token stays in RULECMS_TOKEN, with no NEXT_PUBLIC_ prefix, so it never reaches the browser.

// app/page.tsx
import { RuleCMSWidgetServer } from '@rulecms/widget-react/server';
import { WeekendBannerClicks } from '@/components/WeekendBannerClicks';

export default function HomePage() {
  return (
    <WeekendBannerClicks>
      <RuleCMSWidgetServer
        publishedKey={process.env.RULECMS_WEEKEND_BANNER_KEY!}
        token={process.env.RULECMS_TOKEN!}
        libraries={{
          default: () => import('@rulecms/source-components-react'),
        }}
      />
    </WeekendBannerClicks>
  );
}

The wrapper

// components/WeekendBannerClicks.tsx
'use client';

import type { ReactNode } from 'react';
import { RuleCMSButtonClicks } from '@rulecms/widget-react/button-clicks';
import { usePreorderDrawer } from '@/components/preorder-drawer';

// The Component id from the Modify drawer. Deleting the button and
// adding a new one gives it a new id.
const PREORDER_BUTTON_ID = 'b721c4e2-8f0a-4c31-9a77-1d5e3f0b2c44';

export function WeekendBannerClicks({ children }: { children: ReactNode }) {
  const { openDrawer } = usePreorderDrawer();

  return (
    <RuleCMSButtonClicks
      handlers={{
        [PREORDER_BUTTON_ID]: () => openDrawer('weekend-special'),
      }}
    >
      {children}
    </RuleCMSButtonClicks>
  );
}

What a visitor gets:

  1. The server sends the finished banner. Pre-order now is a real button, styled the way marketing set it.
  2. For a moment, before the page's JavaScript loads, the button does nothing. There is no link to follow.
  3. Once the page has loaded, a click, the Enter key, or the Space key opens the drawer.

Play every product's video

Rise & Crumb has a card for each bake: Sourdough, Focaccia, the weekend special. Each card is its own widget, and each one has a Watch the bake Button. Marketing sets that button's Link to the video for that bake, a normal https:// address ending in .mp4.

Collecting every card's Component id would mean a code change whenever marketing adds a bake. forEveryButton is the other way. One handler sees every RuleCMS button inside the wrapper and decides from the link.

// components/ProductCardClicks.tsx
'use client';

import type { ReactNode } from 'react';
import { RuleCMSButtonClicks } from '@rulecms/widget-react/button-clicks';
import { useVideoPopup } from '@/components/video-popup';

export function ProductCardClicks({ children }: { children: ReactNode }) {
  const { openVideo } = useVideoPopup();

  return (
    <RuleCMSButtonClicks
      forEveryButton={({ event, href }) => {
        if (href?.endsWith('.mp4')) {
          event.preventDefault();
          openVideo(href);
        }
      }}
    >
      {children}
    </RuleCMSButtonClicks>
  );
}

Wrap each card's RuleCMSWidgetServer in ProductCardClicks, or wrap the whole list once. A click on Watch the bake opens the popup and stays on the page, because preventDefault() cancels the navigation the link would have done. A button whose link is not a video is left alone, and the browser follows it.

Until the JavaScript loads, Watch the bake is an ordinary link. A click in that moment opens the video file itself. That is a reasonable fallback, and it is why the Link is worth setting even when your handler will take over.

The handler is told five things: event, the React click event; button, the element that was clicked; href, the Link, or null when the button has none; label, the visible text, trimmed, and empty for an icon-only button; and columnPath, the Component id path, or null when the button was rendered by a library older than 20.22.0. A click on the button's icon counts as a click on the button.

Count a click and still go to the page

Our story links to /about. The handler should record the click and then get out of the way. Leave out preventDefault() and the browser navigates after your code runs.

<RuleCMSButtonClicks
  forEveryButton={({ href, label }) => {
    track('banner_click', { href, label });
  }}
>
  {children}
</RuleCMSButtonClicks>

Do this only for buttons where following the link is the right outcome. On Pre-order now, which has no link, there is nothing to follow. On Watch the bake, forgetting preventDefault() sends the visitor to the raw video.

One special button, and a rule for the rest

A landing page shows the Weekend Special banner and a row of product cards, and you wrap the whole page section once. Pre-order now is named in handlers. Every other button falls through to forEveryButton, which plays a video when the link is one and counts the click otherwise.

<RuleCMSButtonClicks
  handlers={{
    [PREORDER_BUTTON_ID]: () => openDrawer('weekend-special'),
  }}
  forEveryButton={({ event, href, label }) => {
    track('banner_click', { href, label });
    if (href?.endsWith('.mp4')) {
      event.preventDefault();
      openVideo(href);
    }
  }}
>
  {children}
</RuleCMSButtonClicks>

Pre-order now does not get counted here, because a named handler replaces forEveryButton for that button. If the drawer button should be counted too, count it inside its own handler.

Name one copy of a repeated button

A button inside a collection has the same Component id everywhere that collection is embedded. A plain id in handlers reaches every copy. To reach one copy, prefix the id with the collection's Embedding id, the same path Custom Props and Click Handlers uses. The longer path wins.

handlers={{
  // every copy of the collection
  'inner-id': () => trackClick(),
  // only the copy embedded at this spot
  'embedding-id/inner-id': () => openDrawer('weekend-special'),
}}

You rarely need this for the video case. forEveryButton already sees each copy, and the link tells them apart.

Two wrappers on one page

Wrappers can be nested. The product grid has its own ProductCardClicks, and the page has another wrapper around the grid and the banner.

The inside wrapper goes first. If it has a handler for that button, it calls it, and the outside wrapper does not call a second one. If the inside wrapper has nothing for that button, the click reaches the outside wrapper. Clicks keep travelling through the rest of your page, so an onClick you wrote on a parent of your own still runs.

What marketing changes, and what breaks

Marketing does thisWhat happens to the handler
Restyles the button, or rewrites its labelNothing. The Component id stays.
Changes the LinkA forEveryButton handler sees the new link on the next page load. A handler named by id does not read the link unless you ask it to.
Turns on DisabledThe handler never runs. Disabled is the author's decision and it wins, including a disabled button that still has a Link.
Deletes the button and adds a new oneThe new button has a new Component id. A handlers entry for the old id silently does nothing. forEveryButton is unaffected, because it never used the id.
Publishes the widget from Development to ProductionThe Component id is the same in every environment.

Where else it works

The wrapper does not care how the widget got onto the page. It works around RuleCMSWidgetServer, and around RuleCMSWidget in pre-fetched mode and in the default client-fetch mode. On a widget you already render as a client component, componentProps is usually simpler, because the handler is a prop on that one widget and you do not add a wrapper. If a button has both, both run.

It also works when the same button is laid out differently for a phone than for a desktop. A click still calls the handler once.

The HTML embed cannot use this. <rulecms-widget> renders outside your React tree, so a click inside it never reaches the wrapper. On a WordPress page, a button can only follow the Link marketing set.

Check it

Build and start the site, then look at the HTML the server sends. The button is already in it:

curl -s http://localhost:3000 | grep -o 'data-rulecms-button[^>]*>[^<]*'
# data-rulecms-button="" ...>Pre-order now

Then load the page, wait for it to finish loading, and click. The drawer opens, or the video plays, and the address bar does not change unless you wanted it to. Click once more with JavaScript disabled, or in the instant before the page finishes loading: a linked button follows its link, and a plain button does nothing. That is the fallback your visitors get on a slow connection.

Things that trip people up

  • The import. @rulecms/widget-react/button-clicks. The main entry does not export the wrapper, and importing from it ships the whole client package.
  • The wrapper has to be a client component. The page can be a Server Component. The file that renders RuleCMSButtonClicks starts with 'use client', because that is where the handler is created.
  • An id from an old button. Replacing a button in the composer changes its Component id. forEveryButton does not have this problem.
  • A video that navigates away. The Link is a real link. Your handler has to call preventDefault() to keep the visitor on the page.
  • Older buttons and named handlers. Buttons rendered by @rulecms/source-components-react before 20.22.0 do not carry a Component id in the HTML. handlers cannot find them. forEveryButton can, and it reports columnPath as null.
  • A button portalled somewhere else. The wrapper only sees buttons inside its own part of the page. A button rendered into a modal that lives elsewhere in the document is outside it.

What to read next

  • Server Rendering with Pre-fetched Mode — The other way to get the banner into the first HTML and keep a click handler, and the one that also works outside the Next.js App Router.
  • Custom Props and Click Handlers — componentProps on a client-rendered widget: the Component id, a button inside a collection, and a disabled Button.
  • Button — What marketing can set: the label, the Link, the style, and Disabled.
  • HTML Custom Element Embed — The WordPress path, where a button can only follow its Link.

How this fits

Three ways to put a click handler on a RuleCMS button. They differ in who writes it, and in what has to be true about the page.

ApproachWho it is forWhat it costs
Link, set in the composerThe button goes to a URL. Marketing owns it, and no code changes when the URL changes.None. It works in the first HTML, before any JavaScript, and in the HTML embed.
componentProps on RuleCMSWidgetA client-rendered widget, including pre-fetched mode, and a handler for one button you can name.The widget's own JavaScript. The walkthrough is Custom Props and Click Handlers.
RuleCMSButtonClicks around RuleCMSWidgetServerA Next.js App Router page that should send the finished widget and almost no widget JavaScript, including one handler for every card.About 0.4 KB gzip, plus your own handler. This page.

Marketing's job does not change. They compose the button, set its Link when the button goes somewhere, and publish. Your wrapper is what turns that published button into the drawer, the player, or the counter.