Skip to content

Placements

Placements are the SDK’s core output — they’re the decisions about what content to render in which slot, for which user, at what moment. This guide covers placement configuration, resolution, lifecycle, and chaining.

  1. You define slots in your UI — named positions where placements can appear
  2. The SDK evaluates targeting rules against the current user context
  3. If a rule matches, the SDK resolves a placement decision with content, surface template, and CTA path
  4. The slot component renders the decision using a built-in or custom template

Every slot needs an id and optionally a list of accepted surfaceTemplateIds:

React
<Slot
id="dashboard_banner"
surfaceTemplateIds={['banner_placement', 'banner_announcement']}
/>
FieldTypeDescription
idstringUnique slot identifier (matches server-side placement rules)
surfaceTemplateIdsstring[]Templates this slot accepts (filters available placements)

Always-visible inline placements — buttons, cards, meters:

App.tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Carol is on Starter with an active trial — the engine serves her an upgrade
// button in this slot. Switch to user="user_eve" and it renders nothing.
export default function App() {
return (
<DemoApp user="user_carol">
<Slot id="nav_bar_right" surfaceTemplateIds={["button"]} />
</DemoApp>
);
}

Opens a live, editable sandbox with the published SDK.

Fixed slots render nothing if no placement matches — your layout stays intact.

Try it live → Upgrade Button

Wrap premium content and show an upgrade prompt when access is denied:

App.tsx
import { Gate } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// Dan lacks data_export, so the gate renders the configured upgrade placement
// in place of the children. Switch to user="user_alice" to see the children.
export default function App() {
return (
<DemoApp user="user_dan">
<Gate
id="editor_export_action"
surfaceTemplateIds={["modal_overlay"]}
can="data_export"
>
<button>Export Video</button>
</Gate>
</DemoApp>
);
}

Opens a live, editable sandbox with the published SDK.

If the entitlement is granted, children render normally. If denied, the gate renders an upgrade placement in their place.

That denial UI is itself a placement, not hardcoded markup: the decision engine picks the winning placement for the gate (scoped by surfaceTemplateIds) and renders its content — modal or banner, headline, offer, and CTA. So what a denied user sees is controlled by your monetization config and can be changed, targeted, or A/B-tested in the dashboard without a code deploy. deniedFallback is shown only when no placement matches.

Try it live → Data Export Gate

Page-level overlays triggered by targeting rules — toasts, modals, banners:

App.tsx
import { Slot } from "@revturbine/sdk";
import { DemoApp } from "./DemoApp";
// A message slot renders nothing until a targeting rule fires. Alice's usage
// trips the warning rule, so the engine serves her a banner here.
export default function App() {
return (
<DemoApp user="user_alice">
<Slot id="global_banner" surfaceTemplateIds={["banner_placement"]} />
</DemoApp>
);
}

Opens a live, editable sandbox with the published SDK.

Message slots render nothing until a targeting rule triggers. Place them at the top level of your layout.

Try it live → Usage Warning Banner

When a placement resolves, the decision contains:

TypeScript
interface PlacementOutput {
output_id: string;
category: string;
surface: {
type: RevTurbineComponentType;
template?: string;
slot_id?: string;
};
content: Record<string, unknown>; // toast duration is in seconds
cta_path?: Record<string, unknown>;
promotion?: Record<string, unknown>;
rule_id: string;
decision_id: string;
config_version: string;
present_upsell: boolean;
}

config_version lets you detect a stale Playbook; decision_id is the ID to quote in debugging and analytics; present_upsell tells you whether the placement carries an upgrade or expansion offer.

┌─────────┐ ┌──────────┐ ┌──────────┐
│ Resolve │ ──► │ Visible │ ──► │ Interact │
└─────────┘ └──────────┘ └──────────┘
│ │
│ ┌─────┴─────┐
│ │ dismiss │
│ │ snooze │
│ │ ctaClick │
│ │ ctaComplete│
│ └───────────┘
│
▼
(auto-tracked impression)
React
const { dismiss, snooze, ctaClick, ctaComplete } = usePlacement({ placement: { name: 'hero_banner' } });
// Dismiss — suppresses for default cooldown
await dismiss();
// Dismiss with custom cooldown (30 minutes)
await dismiss(30 * 60 * 1000);
// Snooze — temporarily suppress (1 hour)
await snooze(3600);
// Record CTA click
await ctaClick();
// Record CTA completion (e.g., checkout finished)
await ctaComplete();

Each interaction is automatically tracked as a treatment interaction event.

Placements can author cap policies that limit how often they appear:

TypeScript
const capPolicy = {
caps: {
max_per_period: { count: 3, period: 'session' },
cooldown_days: 1,
},
};

Authoring a cap in the Playbook enables client-side cap enforcement for that placement. The decision API returns visible: false with a cap_exceeded reason when capped; the convenience getPlacement() API returns null.

A CTA can point to another placement via cta_path.placement_handle:

TypeScript
import { parseUiPath } from '@revturbine/sdk';
// First placement decides: show upgrade banner
const banner = await sdk.getPlacement({ slotId: 'dashboard_banner' });
// banner.cta_path = { type: 'open_rt_placement', placement_handle: 'checkout_offer' }
// On CTA click, resolve the follow-up placement
const path = banner?.cta_path ? parseUiPath(banner.cta_path) : undefined;
const followUp = path?.placement_handle
? await sdk.getPlacement({ placementHandle: path.placement_handle })
: null;

Every placement carries a cta_path — the action its button triggers. Its type is the action_type of a content_ui_paths entry in your Playbook: one of the built-in types (open_checkout, view_plans, book_demo, contact_sales, complete_onboarding, invite_teammate, refer_friend, verify_work_email, update_payment_method, enable_auto_renewal, manage_subscription, switch_billing_period, extend_trial, open_rt_placement, dismiss, snooze) or a name you define — a custom action flows through under its own name. The starter Playbook uses navigate_to_plans and open_checkout_modal.

The SDK never navigates, opens checkout, or calls your backend on its own. On click it looks up a resolver for cta_path.type and calls it with the authored cta_path record (plan_handle, promotion_id, placement_handle, url, plus any custom params). If no resolver is registered, it tracks the click and calls the slot’s onCtaClick fallback — nothing else happens. snooze has a built-in resolver; everything else needs one from you.

Register resolvers once, at init, keyed by action type:

React
<RevTurbineProvider
options={{
// ...
uiPathResolvers: {
navigate_to_plans: () => router.push('/pricing'),
open_checkout_modal: (path) => openCheckout(path.plan_handle, path.promotion_id),
open_rt_placement: async (path) => {
const next = await rt.getPlacement({ placementHandle: path.placement_handle });
if (next) showPlacement(next);
},
},
}}
>
<YourApp />
</RevTurbineProvider>

At init the SDK checks that every action_type in content_ui_paths has a resolver and warns about gaps; rt.validateUiPathResolvers({ throwOnMissing: true }) turns the warning into an error, which is useful in tests. Actions that must run on your server — extend_trial, for example — go in serverActions instead: see Server-owned CTA actions.

For custom rendering, use usePlacement directly:

React
const {
visible,
content,
decision,
isLoading,
error,
refresh,
dismiss,
ctaClick,
} = usePlacement({
surfaceSlot: {
id: 'dashboard_banner',
surfaceTemplateIds: ['banner_placement'],
},
ttlMs: 300_000, // Cache for 5 minutes
autoLoad: true, // Load on mount
});

One scenario runs here; the full gallery — every built-in slot type, editable, switching demo users — lives on the Interactive Playground.

Upgrade Button — an always-visible CTA in a nav slot.

Example.tsx
import { Slot } from "@revturbine/sdk";
export function Example() {
return (
<Slot
id="nav_bar_right"
surfaceTemplateIds={["button"]}
/>
);
}

More on the Playground: Plans & Pricing Card · Quota Meter · Annual Nudge Banner · Usage Warning Banner · Usage Exhausted Modal · Trial Welcome Toast.

Every slot stamps the app route it rendered on onto its lifecycle events, so discovery groups the same slot across /projects/42 and /projects/43 instead of treating each URL as a separate surface. See Route surface in the Placement Cookbook for the <RevTurbineRoute> boundary, the Next.js recipe, and the pathname fallback.