Contents

Text Placeholders

Maya's homepage hero says hello. She wants that hello to use the visitor's first name, and to say there when her app does not have one. The composer cannot know who is looking at the page. She writes the sentence once, with a placeholder in the Text, and the app passes the name when the page renders.

What you can do

Maya already has her homepage hero on her local site from Development Integration. The headline is a Text she wrote in the composer: "Welcome back." She wants it to say the visitor's first name, and to say "there" when her app does not have one.

The composer cannot know who is looking at the page. That name lives in her app — a logged-in account, a product this URL is about, an order the checkout just created. She should not have to rebuild the headline in React to change three words.

She leaves a placeholder in the sentence and passes the values when the widget renders. RuleCMS keeps the sentence, the styling, and the fallback. Her app keeps the data. Publishing updates the sentence. It does not publish the visitor's name.

The headline she types:

Welcome back, {{ user.firstName ?? there }}!

With firstName set to Maya, the page shows Welcome back, Maya! With no name, it shows Welcome back, there!

When you will want it

Reach for a placeholder when the author should keep owning the sentence, and a few words of it change from page to page or from visitor to visitor.

JobWhat she typesWhat the app passes
Greet whoever is logged in{{ user.firstName ?? there }}The account on this request
One product page, one hero, the product this URL is about{{ product.name }} — {{ product.price ?? Contact us }}The product her app already loaded for this route
Order confirmationOrder {{ order.id ?? your order }} is confirmed.The order the checkout just created
A count she does not want to hard-code{{ cart.items.length }} items in your cartThe cart array. length is read from it.
A value several levels down in an API payload{{ data.response.list[0].lastname }}The payload, as an object or as a JSON string

The same published widget can say something different on every page that embeds it. The product page passes that product. The homepage passes the visitor. The sentence stays the one she published.

Leave it for another page when

  • The words are the same for every visitor. Type them in the composer. A placeholder with no varying data is extra wiring.
  • The click should run her code. That is a Button and Custom Props and Click Handlers. A placeholder only prints text.
  • The block is a component she wrote. A form, a chart, a booking widget. That is Mount Your Own Components. Placeholders fill a sentence. They do not render a component.
  • She wants one collection to repeat once per row of an API response. A collection is a layout she authored. Every copy of it shares the Component id and the sentence, so every copy fills in the same words. One Text per sentence, each with its own id, or render that list in the app.
  • The words are inside a List or an Accordion. Only Text fills placeholders. On List and Accordion the braces stay on the page exactly as typed, even when placeholderValues is passed to that column.

How it thinks

Two pieces, and they meet only at render time.

  • The sentence is Content Text on the Text component. She writes it, styles it, and publishes it. Placeholders are part of that sentence.
  • The values are placeholderValues, an object her app passes. One object on RuleCMSWidget fills every Text in the widget. A componentProps entry that sets placeholderValues itself wins for that one Text. RuleCMS does not store the values. Paths in the sentence are read from that object.

A placeholder is two opening braces, a path, an optional fallback, and two closing braces:

{{ user.firstName ?? there }}

user.firstName is the path. ?? and the words after it are what to show when that path has nothing to print. Spaces just inside the braces are optional, so {{ user.firstName }} and {{user.firstName}} are the same placeholder.

Double braces are deliberate. Single braces already mean formatting in Content Text: {b|bold}, {1|pricing}, {br|} for a line break. A placeholder uses two braces so a formatting marker and a path can sit in the same sentence, and so a brace she typed by accident still shows up instead of disappearing.

She does not need a Component id to fill every Text from the same object. Pass placeholderValues on RuleCMSWidget. When one Text needs a different object, its address is its Component id, the same id Custom Props and Click Handlers uses for a Button. She copies it from the Modify drawer. The key in componentProps picks that Text, and placeholderValues inside the entry replaces the widget-wide object for that Text only. Other keys in the entry stay. Passing placeholderValues to a component that does not fill placeholders changes nothing, and the page stays quiet about it.

A missing step along the path prints the fallback, or nothing if she wrote no fallback. It does not throw, and it does not take the rest of the widget down with it. 0 and false count as real values and print. An object, an array, or a missing key does not.

Whatever the path finds is inserted as text, after formatting is worked out. A value that happens to contain {1|click}, {{ other.path }}, or <b>sale</b> shows those characters. It does not become a link, a second placeholder, or HTML.

Write Maya's greeting

She does this after the hero is on her local site: a dev. token and the draft widget-… key from Integrate. The Default Widget is Production-only. She created this hero in Development.

  1. Open the homepage hero in the composer.
  2. Click the greeting Text. In Content Text, write {{ user.firstName ?? there }} where the name should go. Set an Enclosing Tag — a heading or a paragraph — the same way she would for any other Text.
  3. On the canvas the placeholder stays as she typed it, so she can read the path and edit the sentence around it. Open Preview and she will see there, the fallback. Preview has no visitor. That is what a visitor sees when the app passes no values.
  4. In her app, pass placeholderValues on RuleCMSWidget. Paths start inside that object, so user.firstName means firstName on user. Every Text in the widget reads it. A later section shows how one Text gets a different object.
'use client';

import { RuleCMSWidgetProvider, RuleCMSWidget } from '@rulecms/widget-react';
import * as sourceComponents from '@rulecms/source-components-react';

const token = process.env.NEXT_PUBLIC_RULECMS_DEV_TOKEN; // must start with "dev."
const widgetKey = 'widget-…';

export function HomepageHero({ firstName }: { firstName?: string }) {
  return (
    <RuleCMSWidgetProvider
      token={token}
      libraries={{ default: sourceComponents }}
    >
      <RuleCMSWidget
        publishedKey={widgetKey}
        placeholderValues={{ user: { firstName } }}
      />
    </RuleCMSWidgetProvider>
  );
}

That is a safe stop. Save. Refresh. The greeting uses the name she passed. Clear firstName and the fallback there shows. The rest of the hero still looks the way she styled it.

Keep placeholderValues stable across renders — build it outside the component, or memoize it. A fresh object on every render makes the widget re-render along with it. The values themselves are plain data. They do not need useCallback.

Delete the Text and add another one, and the new Text still reads the widget-wide object, because that object is not addressed by id. An override is different. Its Component id stays put when she edits the sentence, the fallback, or the column styles. A new Text has a new id. Re-copy it.

Paths

A path is one or more keys, separated by dots, with an optional [0] when the next step is an item in an array. Keys are the names in her JSON: letters, digits, underscores, and hyphens. They are case-sensitive. lastname and lastName are different keys.

She writesIt reads
{{ user }}The user key. If that value is an object, it has nothing to print — point the path at a field inside it.
{{ user.firstName }}firstName inside user
{{ data.response.list[0].lastname }}lastname on the first item of list, under response, under data
{{ data.response.list.0.lastname }}The same item. A dot and a number is a position.
{{ cart.items.length }}How many items are in the array
{{ 0.name }}name on the first item, when placeholderValues itself is an array

Only keys the object actually has are read. A path cannot wander onto methods inherited from the language, so {{ user.constructor }} prints the fallback unless her data really has a key by that name.

Anything between the braces that is not a path plus an optional fallback stays on the page, braces and all, so she can see the mistake:

  • A space inside the path — {{ user name }}, {{ user. firstName }}
  • Quotes, brackets-as-code, or an empty pair — {{ user["firstName"] }}, {{ }}, {{ [0] }}
  • A key the grammar cannot spell, such as a space or an accent — {{ prénom }}
  • A pair that was never closed — {{ user.firstName
  • Single braces — {user.firstName} is ordinary text. It is not a placeholder.

What a value prints as

The path findsThe page shows
A stringThat string
A number, including 0The number. A fallback after ?? is skipped, so a price of 0 stays 0.
true or falseThe words true or false. false is a value, so the fallback is skipped.
Nothing there, null, or ""The fallback, or nothing if she wrote no fallback
An object, an array, a function, or a number that is not finite (such as NaN)The fallback, or nothing. An object never becomes the text [object Object]. Point the path at a string, number, or boolean inside it.

An empty string uses the fallback too. If firstName is "", {{ user.firstName ?? there }} shows there, so the sentence does not collapse to "Welcome back, !".

Fallbacks

The fallback is everything after the first ??, with surrounding spaces removed. It prints exactly as she typed it. Quotes are characters, so {{ user.firstName ?? "friend" }} shows the quote marks. Write {{ user.firstName ?? friend }} when she wants the word alone.

Write a fallback on every placeholder a visitor might see without data. Three places have no placeholderValues at all, and in all three the fallback is the sentence:

  • Preview in the composer. The canvas shows the braces so she can edit them. Preview shows the fallback.
  • Any page whose RuleCMSWidget passes neither placeholderValues nor a componentProps entry for that Text.
  • RuleCMSWidgetServer and the HTML Custom Element Embed, which cannot take componentProps yet. Visitors there see the fallback. Details are under Where the words fill in.

A fallback can be a whole phrase. {{ product.price ?? Contact us for pricing }} is one placeholder.

Passing JSON

placeholderValues can be the object, or a string of JSON for an object or an array. The string form is for an app that already holds the payload as JSON. Both of these fill {{ user.firstName }} with Maya:

placeholderValues: { user: { firstName: 'Maya' } }

placeholderValues: JSON.stringify({ user: { firstName: 'Maya' } })

A string that is not JSON for an object or an array — a bare number, a bare string, or text that merely looks like JSON — counts as no values. Every placeholder on that Text shows its fallback, or nothing. Outside production the console says the string was not usable, and quotes the start of it. The page does not throw.

A number, a boolean, or a function passed as placeholderValues itself is the same: no values, a console note outside production, fallbacks on the page.

More than one Text

One placeholderValues on RuleCMSWidget is the widget-wide object. Every Text reads it. The greeting uses {{ user.firstName ?? there }}. The plan line uses {{ account.plan ?? Free }}. She does not copy a Component id for each of them.

const values = { user: { firstName: 'Maya' }, account: { plan: 'Pro' } };

<RuleCMSWidget
  publishedKey={widgetKey}
  placeholderValues={values}
/>

A Text that needs a different object sets placeholderValues on its own componentProps entry. That entry wins. Copy its Component id from the Modify drawer — the accordion at the top starts closed. Other keys on the entry stay, so a handler and a different object can share it. To leave one Text on its fallbacks while the rest of the widget fills in, set placeholderValues to null on that entry.

componentProps={{
  [SPECIAL_TEXT_ID]: { placeholderValues: { user: { firstName: 'Sam' } } },
  [LEGAL_TEXT_ID]: { placeholderValues: null },
}}

An entry on componentProps still works on its own when the widget prop is omitted. That is the same map a Button handler uses.

Copies of a collection

A collection embedded twice has two copies of every Text inside it, and those copies share the inner Component id. The widget-wide object fills every copy. A bare id in componentProps that sets placeholderValues also reaches every copy, and the copies share the sentence, so they fill in the same words. That is the right outcome for a label that should match. It is the wrong tool for "card 1 is product A, card 2 is product B."

To give one embedding a different object, prefix the inner id with that collection's Embedding id, with a slash. The longer path wins, the same way it does for a Button handler:

componentProps={{
  [INNER_TEXT_ID]: { placeholderValues: { product: { name: 'Everyday' } } },
  [`${EMBEDDING_ID}/${INNER_TEXT_ID}`]: {
    placeholderValues: { product: { name: 'Weekend' } },
  },
}}

Embedding id is on the collection itself. Click the collection in the composer. The drawer labels its id Embedding id. The full addressing rules — a bare id, a longer path, which one wins — are on Custom Props and Click Handlers.

Marks, links, and the filled words

A placeholder can sit inside bold, italic, strikethrough, or a link, because it is part of the sentence. On the canvas, select the placeholder and use the toolbar. In Source, put the placeholder in the marker's text:

Welcome back, {b|{{ user.firstName ?? there }}}!
See {1|{{ product.name ?? this product }}}.

The mark or the link wraps the words after they are filled in. Bold stays bold. The link still goes where the Links setting says.

If a marked or linked run fills in as nothing — the path is empty and she wrote no fallback — that bold or that link is left out. There is no empty clickable gap. A fallback is how the link keeps a label.

Link addresses and class names are used as she authored them. Placeholders in those fields are not filled.

Where the words fill in

Where she is lookingWhat she sees
The composer canvasThe placeholder, braces and all. The composer never receives her app's values, and showing a blank there would hide the path she is editing.
Preview, and any render with no values for that TextThe fallback, or nothing
RuleCMSWidget while it fetches the widget itselfThe filled sentence, once the widget has rendered. The values are on the widget, so they show up with it.
RuleCMSWidget in pre-fetched modeThe filled sentence in the HTML the server sends. The values are plain data, so they do not wait for the browser to attach them. The walkthrough for fetching on the server is Server Rendering with Pre-fetched Mode.
RuleCMSWidgetServerThe fallback. It cannot take placeholderValues or componentProps yet. To put the filled words in server HTML, use pre-fetched mode.
<rulecms-widget>The fallback. The embed's API is HTML attributes, and attributes cannot carry this object. A page that needs the filled words uses @rulecms/widget-react.
List, AccordionThe braces, exactly as typed. Those components do not fill placeholders.

A fallback is the sentence everywhere the app's values cannot arrive. Write {{ user.firstName ?? there }}, not {{ user.firstName }}, on any Text a visitor might see from Preview, from RuleCMSWidgetServer, or from the HTML embed.

RuleCMS stores the sentence with the braces. Her app fills them when that page renders. A cache of her page's HTML will contain the words from the render that produced it. The widget snapshot itself still has the placeholders.

When a path is wrong

Outside production, the browser console says what happened, once per distinct message. In production it stays quiet and the visitor sees the fallback.

What she sees in the consoleWhat it usually means
RuleCMS: {{ user.lastname }} found nothing in placeholderValues. Paths are case-sensitive.The key is missing, or the case differs — lastname against lastName. A step in the middle of the path can be the one that is missing.
RuleCMS: {{ user }} found an object in placeholderValues, which prints as nothing. Point it at a string, number or boolean.The path landed on an object, an array, a function, or a non-finite number. Add the next key.
RuleCMS: placeholderValues is a string, but not JSON for an object or array…The string was passed through, and JSON.parse could not turn it into an object or an array.
A componentProps key matches no componentThe Component id is from another widget, the Text was deleted and re-added, or a character was dropped while copying. The warning is the one documented on Custom Props and Click Handlers.

Two silences are on purpose. A null or an empty string does not warn: that is the app saying there is no value, and the fallback is the intended result. Passing no placeholderValues at all does not warn either: that is Preview, and every page that is not using placeholders.

Nothing warns when the id matches a List or an Accordion. From the widget's side, handing values to a component that does not read them looks the same as handing them to one that does. If the braces are still on the page, the component is not Text.

What to read next

How this fits

This page does not replace the sentence. Text is still where she writes it, styles a word, and chooses the tag. Placeholders are words in that sentence whose values arrive from the app.

It rides on the same map as a click handler. Development Integration is how the hero gets onto the page. Custom Props and Click Handlers is how a column id carries something the composer cannot know. For Text, that something is placeholderValues.

The Default Widget is in Production. She built this hero in Development. Promote copies the draft to the next environment; it does not publish. When she publishes, she switches the app to a Staging or Production token — no dev. prefix — and that published key. The Component id does not change, and the placeholders are in the snapshot. She keeps passing placeholderValues from the app. Do not mix a dev. token with a published key, or a published token with a draft widget-… key.