Skip to content
All articles
Engineering

We Let Other People's React Run Inside Our App

A plugin is one React component, bundled to a single file and evaluated on our page — no iframe. The sandbox injects everything it may touch; the server believes nothing it says.

Rushit Jivani6 min read

The short version

  • Plugins are single React components. The sandbox evaluates the bundle with injected globals — React, a data API, our UI kit — so a plugin imports nothing and reaches nothing we didn't hand it.
  • All reads and writes go through a server-side proxy, scoped to collections the plugin's manifest declares. The server revalidates the whole manifest on every save, and pricing fields are sticky: a payload that drops one is an error, not a removal.
  • Trust is a ladder: private → awaiting review → approved → certified → first-party. One-click instant install exists only at the top, and AI access to a plugin is off until its author turns each tool on.

Every product eventually gets the feature request it shouldn't build. Niche workflow, one-customer integration, perfect sense for eighty users and none for the roadmap. You can say no forever, or you can let people build it themselves — which means running strangers' code inside your app, next to your users' data.

This is the honest build log: the sandbox, the manifest rules, the trust ladder, and the parts that broke first.

What I actually built

Not an app store. A plugin is a directory with two required files, and everything else is the platform's job:

index.tsx

The root component. Bundled into one JS file, stored in the database, evaluated at runtime inside the page.

plugin.config.json

The manifest: what the plugin is, which collections it touches, config, pricing. A request, not a fact.

The sandbox

Evaluates the bundle with injected globals. The host decides what plugins can reach; the server decides whether manifest and reality agree.

To the person using the product it's just another section of the screen, in the product's own look.

The trick that makes it survivable: the plugin imports nothing. The sandbox evaluates the bundle with globals in scope — React, pluginApi, pluginComponents, contextData, userRole — via a with(sandbox) wrapper. Anything not injected does not exist.

The sandbox contract

injected into scope

  • Reactthe host's copy
  • pluginApithe data proxy
  • pluginComponentsthe host's UI kit
  • contextDatawhere it's running
  • userRolewho's looking

does not exist

  • import …no module resolution
  • a second Reactrejected at validation
  • the databaseonly the proxy exists
If the host didn't hand it over, the plugin can't reach it.
The bundle is evaluated inside a with(sandbox) wrapper. The left column is the plugin's entire world.

Why not iframes

An iframe per plugin buys you the browser's isolation and costs you everything else:

iframeIn-page sandbox
IsolationThe browser'sYours to enforce
Native lookRebuilt by every authorInherited from the host
Data accessA postMessage protocolOne proxy function
Author shipsA hosted appOne component file
Failure modeA broken frameA broken section — or worse

We wanted plugins that look native — same components, same theme, same loading states — and authors who ship one file. That trade means isolation is our code, not the browser's. The rest of this article is that code.

Roles are the platform's job

Early plugins rendered one component and checked who was looking from the inside — if (userRole === "host") scattered through the tree. Every author got it slightly wrong. A config panel flashed for guests before the check ran.

Now a plugin declares separate views — guest, host, host-config — and the sandbox renders exactly one, chosen by the host app from the viewer's real role. The role still arrives in contextData for small decisions inside a view, but the dangerous cut — admin surface versus public surface — is made by code the author can't get wrong.

Who sees what — try it

The viewer is…

anonymous — not signed in at all

rendered

guest view

The public surface. The role still arrives in contextData for small decisions inside it.

not rendered

host view

The admin surface: moderation, management, the dangerous buttons.

not rendered

host-config view

The settings screen, shown only when a host opens configuration.

The plugin declares all three views. The host app renders exactly one, chosen from the viewer's real role — a cut the author can't get wrong.

One detail that mattered more than expected: the role vocabulary includes the awkward states. logged_in means authenticated but not a member. anonymous means not signed in at all. Early plugins assumed everyone was a member and rendered garbage for everyone else. Name those states in the contract and authors handle them.

One proxy, no database

The tempting data API is a thin wrapper over your database client. Then every plugin bug is a data incident, and every author reads whatever the page can read.

What shipped is one function:

pluginApi.pluginDataProxy(collectionName, operation, options)
// operations: "list" | "get" | "create" | "update" | "delete"

The collections a plugin may touch are declared in its manifest, with write rules per collection — admin-only by default, member writes only where the manifest opts in. The proxy runs server-side and is the authority: it checks the plugin is actually installed in that scope, the caller's role allows the operation, and the collection belongs to this plugin. The client-side call is a courier.

One write, start to finish

1 · The plugin asks

pluginApi.pluginDataProxy("suggestions", "update", { … })

The call decides nothing. It carries the request.

2 · The server-side proxy checks

  • Is the plugin actually installed in this scope?
  • Does the caller's role allow this operation?
  • Is this collection declared in this plugin's manifest?

3 · The write lands

Under the plugin's own installation, in its own collections. A misbehaving plugin's blast radius stops at its own data.

Collections holding other people's contact details are denied to every plugin, unconditionally. No manifest can ask its way in.

The client-side call is a courier. Every decision happens server-side, against the manifest and the caller's real role.

Two things fell out of this for free. Each plugin's data lives under its own installation, so a misbehaving plugin's blast radius is its own collections, not its neighbors'. And some collections are denied to every plugin unconditionally — anything holding other people's contact details never becomes reachable through the proxy, no matter what a manifest asks for.

A guest view, in the shipped shape — injected globals, a proxy read, a role gate:

// No imports. React, pluginApi, userRole, pluginComponents are injected.
const { useEffect, useState } = React
 
function SuggestionBoard() {
  const [items, setItems] = useState([])
  const canModerate = userRole === "host" || userRole === "admin"
 
  useEffect(() => {
    pluginApi
      .pluginDataProxy("suggestions", "list", { filters: { status: "open" } })
      .then(setItems)
  }, [])
 
  return (
    <pluginComponents.Card>
      {items.map((item) => (
        <pluginComponents.Row key={item.id}>
          {item.text}
          {canModerate && (
            <pluginComponents.Button
              onClick={() =>
                pluginApi.pluginDataProxy("suggestions", "update", {
                  itemId: item.id,
                  data: { status: "closed" },
                })
              }
            >
              Close
            </pluginComponents.Button>
          )}
        </pluginComponents.Row>
      ))}
    </pluginComponents.Card>
  )
}

Whether a member's write actually lands is the server's call, not this file's.

The manifest is a request, not a fact

Updates are where manifests go wrong, not creation. The client merges the stored manifest with the incoming edit, normalizes fields, and sends the result. One day a paid plugin's pricing block simply wasn't in the payload — a merge dropped it — and a paid plugin nearly saved itself free.

The server now revalidates the entire manifest on every write as if it had never seen it: required fields, types, enums, and the pricing rules (tier prices ordered free < basic < pro < premium, limits ordered the same way, a free tier that is actually free). And pricing fields are sticky: once they exist, a payload that omits them throws instead of saving. Deleting pricing is its own explicit action.

The trust ladder

Early on a plugin was installed or not, and "should anyone be able to add this?" had nowhere to live. Now it's a field with five values, set by the platform and never by the author's own write path:

private → awaiting review → approved → certified → first-party

The trust ladder

  1. 5first-partyOurs. The only rung with one-click instant install — and only when the manifest declares requiresZeroConfiguration: true.
  2. 4certifiedA deeper review behind it. Still the same deliberate install flow.
  3. 3approvedInstallable — through a flow that shows what the plugin is and what it wants.
  4. 2awaiting reviewSubmitted. Review effort lands here, where trust is lowest.
  5. 1privateLives only where its author put it.
The rung is set by the platform, never by the author's own write path. Capabilities hang off the rungs.

Capabilities hang off the rungs. Instant one-click install — no config screen, no confirmation — requires two things at once: the plugin is first-party and its manifest declares requiresZeroConfiguration: true. Everything below installs through a flow that shows what the plugin is and what it wants. Review effort lands where trust is lowest.

Zero-config itself started as an inference — skip the settings screen when it "looks empty" — and guessed wrong both ways. It works as a declared manifest flag the review process can check. Anything your install UX wants to know about a plugin should be a declared, reviewable fact, not a guess.

What went wrong first

If I pretended this worked on the first try, it would be an ad.

  • The second React. Covered above, but it was the first and most haunted failure. The fix is validation that rejects a bundle carrying its own copy of a host-provided library.
  • The sticky-field near-miss. A client-side merge stripped a pricing block and the save almost went through. Server-side revalidation plus sticky fields exists because of that one payload.
  • Role checks inside plugin code. Nobody wrote them wrong on purpose; everybody wrote them wrong once. Declared per-role views moved the decision into our code.
  • AI access defaulted open. When we later gave an AI assistant access to the product, plugins became reachable from chat — including ones whose authors never imagined a model as a caller. The default flipped: a plugin with no explicit AI grant can't be touched from chat at all. Grant candidates are derived from what the plugin's code actually calls, and the author enables them one by one, in a separate step from publishing. If you build this now, assume a non-human caller is coming and make its default no.
  • TypeScript lies. Authors need types for globals they never import. A per-plugin ambient declaration file (sandbox.d.ts, no top-level imports or it stops being ambient) fixed the editor experience — and it's manually maintained, so it drifts. Verify against the real API when something seems off.

Steal my workflow

  1. One required component file and one manifest per plugin. Bundle to a single file; externalise everything the host injects.
  2. Inject capabilities as globals. No imports, no module resolution, no reach.
  3. Declare views per role and render exactly one from the host side.
  4. Route all data through one server-side proxy, scoped to manifest-declared collections, with write rules per collection. Deny contact-data collections to everyone.
  5. Revalidate the full manifest on every save. Make money and permission fields sticky.
  6. Put trust on a ladder and hang install UX off the rungs.
  7. Default AI access to nothing; let authors enable derived candidates one at a time.

Start with one real plugin — yours — and move it through the whole ladder before opening the doors. Every rule above came from a plugin that was already running.

FAQ

Iframe when plugins are big hosted apps and isolation outranks looks. In-page sandbox when plugins should feel native and stay one file — accepting that isolation becomes your validation and your proxy.

  • #React
  • #Plugins
  • #Sandbox
  • #PlatformEngineering
  • #Security
  • #Architecture
Rushit Jivani headshot

Written by

Rushit Jivani

Senior Software Architect & AI Application Engineer. Architecture that shows up in crash rate, performance and how fast the team ships.