Decoupling Marketing Copy with the ExperienceSlot Pattern in Next.js
Learn how the ExperienceSlot pattern separates copy from React components, cuts bundle bloat, and eliminates hydration bugs in large Next.js apps.

Why hard‑coded announcements break at scale
When you sprinkle a useEffect check and a modal component throughout your pages, the first few releases feel fine. After a handful of campaigns the JavaScript bundle swells, CSS for hidden elements ships to every user, and you start seeing hydration mismatches because server‑rendered markup no longer matches what the client expects.
In a real SaaS product I worked on, each new banner added ~5 KB of JS and ~3 KB of CSS. Six months later the initial payload was 80 KB larger and a has_seen_v2_modal flag caused a race condition on the homepage. The root cause? Marketing copy and display logic were baked into the component tree.
Enter the ExperienceSlot pattern
The idea is simple: treat every in‑app announcement as data, not code. A central ExperienceProvider reads a JSON payload (or CMS entry) that describes which slots to render, when, and with what copy. The UI components become pure, reusable shells that receive their content via props.
// experienceProvider.tsx
import { createContext, useContext } from 'react';
export type SlotConfig = { id: string; enabled: boolean; copy: string; };
export const ExperienceContext = createContext<SlotConfig[]>([]);
export const ExperienceProvider: React.FC<{config: SlotConfig[]}> = ({config, children}) => (
<ExperienceContext.Provider value={config}>{children}</ExperienceContext.Provider>
);
export const useExperience = (id: string) => {
const slots = useContext(ExperienceContext);
return slots.find(s => s.id===id && s.enabled);
};Slot component – the only place UI lives
The ExperienceSlot component pulls its configuration from the context and renders nothing if the slot is disabled. This keeps the component tree static, so the server‑rendered HTML never changes based on marketing toggles.
// ExperienceSlot.tsx
import { useExperience } from './experienceProvider';
export const ExperienceSlot: React.FC<{id: string}> = ({id}) => {
const slot = useExperience(id);
if (!slot) return null;
return (
<div className="announcement" role="alert">
<p>{slot.copy}</p>
</div>
);
};Feeding the slots from a CMS or feature flag service
In production you usually fetch the config at build time (static generation) or via an edge function. The key is that the payload is a plain JSON object, so marketing can edit copy without a deploy.
# example JSON payload (could live in a headless CMS)
curl https://cms.example.com/api/experience | jq '.'
{
"slots": [
{"id":"welcome-banner","enabled":true,"copy":"Welcome to version 2!"},
{"id":"trial-upgrade","enabled":false,"copy":"Upgrade now and get 20% off"}
]
}Benefits you can measure
- Bundle size: The UI shells are always present, but the copy lives in a tiny JSON file. No extra CSS or JS per campaign.
- Hydration safety: Server markup is deterministic because the same config is used on both sides.
- Team autonomy: Marketing updates the JSON payload; engineers don’t need to touch React code for each banner.
Gotchas and trade‑offs
The pattern adds an indirection layer, so debugging requires checking the config source. Also, if you need per‑user personalization beyond a simple flag, you’ll have to enrich the payload with user attributes or fetch it client‑side, which re‑introduces some latency.
Overall, the ExperienceSlot approach gives you a clean separation of concerns and keeps your Next.js bundle lean, even as the number of in‑app announcements grows.