Documentation

Builder & Plugins

How the section-builder invitation works under the hood, how to author your own plugin components without writing app code, and how developers add new native components.

Architecture

How the canvas works

A builder invitation is one JSON document stored in a single jsonb column (events.builder). That document has a global theme and an ordered list of typed sections - the sections are the page.

The same component, BuilderRenderer, draws that document in two modes: with editing=true it becomes the inline editor (click-to-edit text, drag-to-reorder, per-section toolbar, layers panel, autosave); with editing=false it renders the read-only invitation guests see, with the scroll reveal, falling petals, and end confetti. Because both come from the same JSON, what you build is exactly what ships.

SUPABASEevents.builder (jsonb)BuilderDocthemecolors · fonts · sizessections[ ]ordered blocksBuilderRendererone component, two modesEDITING = TRUEEditorinline edit · dragtoolbar · layersautosaves → jsonbEDITING = FALSEGuest invitationread-only renderscroll reveal · petalsRSVP · confetti

One JSON document → one renderer → both the editor and the live page

Crucially, most design work needs no database changes. New fonts, colors, sizes, section types, and plugin output all live inside that flexible jsonb document - adding them never alters the schema or risks existing data.

Data model

Anatomy of the document

The shape, trimmed to essentials:

BuilderDoc = {
  version: 1,
  theme: {
    bg, text, accent, headingColor,   // colors
    font,                             // curated display font
    nameScale, nameLineHeight,        // couple-name type
    headingScale, headingLineHeight,  // section-heading type
  },
  sections: Section[]                 // ordered - this is the page
}

Section = {
  id: string,          // stable unique id
  type: "hero" | "story" | "timeline" | "gallery"
      | "image" | "embed" | "rsvp" | ...,
  props: { ... },      // data for THIS section's type
  bg?: { image?, color? }   // optional per-section backdrop
}

type narrows props (a discriminated union), so each section is strongly typed. The renderer dispatches on type to the matching React component.

No-code

Create a plugin (no code deploy)

Plugins are WordPress-style custom components you author once and reuse across invitations - a countdown widget, a map, a video, a guestbook embed, any HTML. You create them in Settings → Plugins (open the account menu from the navbar avatar). No developer or deploy needed.

1

Add a plugin

In Settings → Plugins, give it a name, paste your HTML / embed code, set a height, and (optionally) declare editable fields so it can be customised per invitation. It's saved instantly.

2

Drop it into an invitation

Your plugins appear in the builder's component palette under Plugins. Click one and it's added as a section, pre-filled with your code.

3

It ships with the invite

The plugin's HTML is saved into that event's JSON, so every guest sees it - rendered in a sandboxed iframethat's isolated from the app (scripts run, but it can't touch cookies, storage, or the page around it).

Example plugin HTML:

<div style="font-family:sans-serif;text-align:center;padding:24px">
  <h3 style="margin:0 0 8px">Live from our hashtag</h3>
  <a href="https://instagram.com/explore/tags/RaelAndFaisha"
     style="color:#c93766">#RaelAndFaisha</a>
</div>

Where plugins live: your plugin library is saved to your account, so it follows you across browsers and devices. Keep the HTML reasonably small - for anything large, point a plugin at a hosted iframe embed rather than pasting it wholesale.

For developers

Build a plugin

A plugin is an HTML template plus a list of editable fields. You write the markup once and mark the parts the admin should be able to change with {{key}} placeholders. In the builder, each field becomes a control (text box, color picker, number, image upload), and the chosen values are substituted into your template before it renders in a sandboxed iframe.

Field types

  • text- single-line text box(headings, names, labels)
  • textarea- multi-line text box(paragraphs, messages)
  • color- color picker(accent / background colors)
  • size- number input(font sizes, widths, durations)
  • image- image uploader(becomes a hosted image URL)
1

Write the template with placeholders

Plain HTML. Put {{key}} wherever a field's value should appear - in text, in a style, or in an src. An imagefield resolves to the uploaded photo's URL.

<div style="text-align:center;padding:24px;font-family:sans-serif">
  <h2 id="title"
      style="margin:0;color:{{accent}};font-size:{{size}}px">
    {{title}}
  </h2>
  <img src="{{photo}}" alt=""
       style="max-width:180px;border-radius:14px;margin-top:14px" />
</div>
2

Declare the fields

In Settings → Plugins → New plugin, add one field row per placeholder: a key (matches {{key}}), a label, and a type. For the template above:

title   →  Text     (label: "Heading")
accent  →  Color    (label: "Accent color")
size    →  Size     (label: "Heading size")
photo   →  Image    (label: "Photo")
3

Use it in any invitation

Your plugin shows up in the builder palette under Plugins. Adding it drops in a section with a control for every field; editing a value live-updates the preview. The whole thing (template + fields + values) is snapshotted into the event JSON, so it ships to guests and stays editable on any device.

Building a native section type instead (in-repo, with React and inline editing)? That path is documented in the repo at docs/builder-components.md.

Scripts & isolation

Scripts, animation & safety

Yes - plugins can run JavaScript: animations, interactions, even third-party libraries loaded from a CDN. Your code runs inside a sandboxed iframe, which is the key to why it's safe.

No naming conflicts - ever. The iframe is its own document with its own windowand global scope. Functions, variables and library globals you define cannot collide with the host app or with other plugins, and an infinite loop or crash in your script is contained to the iframe - it can't take down the invitation. So there is no list of "reserved" or forbidden function names to avoid; name things whatever you like.

<button id="btn">Tap me</button>
<script>
  // Runs in the plugin's OWN isolated window - any names are fine.
  const btn = document.getElementById("btn");
  let count = 0;
  btn.addEventListener("click", () => {
    btn.textContent = "Tapped " + (++count);
  });
</script>

What the sandbox blocks

The iframe runs with allow-scripts allow-forms allow-popups allow-presentation but not allow-same-origin. That gives it an opaque origin, which is what prevents it from reaching into the app. Concretely, your plugin cannot:

  • read or write the app's cookies, localStorage, or session
  • touch the surrounding page's DOM (window.parent / top are cross-origin and blocked)
  • navigate or redirect the whole browser tab
  • call same-origin-only browser APIs (it has no real origin)

Everything self-contained works: your own JS/CSS, timers, animations, <canvas>, and libraries pulled in via <script src>. Just keep it light - guests open invitations on phones.

Good to know

Notes & roadmap

  • Values are escaped.Field values are HTML-escaped before they're substituted, so a stray <in a text field can't break your markup.
  • Images are optimised. Uploads are downscaled and converted to WebP in the browser, then cached for a year.
  • Plugin library syncs to your account, so it follows you across devices - no separate table required. Keep templates reasonably small.
  • Roadmap: shareable plugin import/export, range/select field types, and a curated plugin marketplace.
Moonix Events · DocsChangelog →