DEV Community

Amith Moorkoth
Amith Moorkoth

Posted on

Lessons from Building Bombie: SPA Deep Links, CSP, and an Iframe Preview

This is the final post in the Bombie series. The earlier ones covered what Bombie is, the JSON-tree architecture, and how to add a new component. This one is about the operational stuff that's invisible when it works — and obvious when it doesn't.

Three lessons, each with the same shape: a problem I didn't anticipate, the fix that landed in the repo, and what I'd do the same way next time.

Lesson 1: SPA deep links on GitHub Pages need a shim

The first time I deployed Bombie to GitHub Pages, the builder route worked from the homepage. Click "Open builder", land at /bombie/generate-component, everything renders. Then I refreshed the page and got a 404.

GitHub Pages serves static files. It doesn't know anything about React Router. When you hit /bombie/generate-component directly, Pages looks for a file at that path, doesn't find one, and returns its 404 page. Vercel handles this with a single rewrites rule in vercel.json — anything that doesn't match a real file rewrites to /index.html, React Router takes over from there. GitHub Pages has no equivalent.

The workaround Bombie uses lives in two files:

  • public/404.html — the 404 page Pages returns when it can't find a file.
  • public/spa-redirect.js — a tiny inline script in that 404 page.

The script reads the unfindable path, encodes it as a query string, and redirects to the root index.html with that query string attached. React Router boots, sees the query string, and pushes the real route into history. The 404 was real but it's now invisible — one extra redirect, no observable difference to the user past the initial flash.

The trick isn't novel (it's the Rafgraph spa-github-pages pattern, well-documented in the React Router community) but the part worth saying out loud is that you'll never notice the problem until you ship to a static host that doesn't do rewrites. Local dev with webpack-dev-server does rewrite to index.html by default. Vercel does too. GitHub Pages doesn't. If your deploy story might ever cross hosts, get this in early.

What I'd do the same next time: write the shim, but also pick one canonical host. Bombie ended up on Vercel (bombie-three.vercel.app) because the rewrite is a one-liner and there's no flash. The GitHub Pages workflow stayed in the repo for anyone who wants to deploy a fork there, with the shim handling the corner case.

Lesson 2: One CSP for dev and prod is wrong

Content Security Policy is one of those things that looks easy on the homepage and gets weird in practice. The default advice ("set script-src 'self'") is correct for production. It's also incompatible with webpack-dev-server's HMR client, which uses eval for hot module replacement.

So you have two options:

  1. Make production weaker so dev works.
  2. Make dev and prod use different CSPs.

Bombie picks option 2. The CSP is set per-mode in the webpack config:

// roughly
const csp = isDev
  ? "script-src 'self' 'unsafe-eval'; ..."
  : "script-src 'self'; ...";
Enter fullscreen mode Exit fullscreen mode

'unsafe-eval' is only in the dev CSP. The production bundle gets the strict policy. There's no 'unsafe-eval' in the running app once you npm run build.

The reason I'm flagging this: it's tempting to drop 'unsafe-eval' into the meta tag and forget about it, because the dev console will stop complaining and prod won't immediately break. But CSP violations don't fail loudly in production — they just block scripts. You won't notice until someone tries to use a third-party widget you added later that happens to call eval.

Set the strict policy in prod from day one, even if you're not currently using anything that would benefit. Then if a future dependency starts calling eval and the CSP blocks it, you'll see the violation immediately and decide whether to allow it explicitly or replace the dependency. With a loose CSP you'd just ship a quiet regression.

The dev-mode 'unsafe-eval' is fine because it never ships. The build pipeline strips the dev block of the config and only the prod CSP lands in the deployed index.html.

Lesson 3: Use an iframe when you need an honest viewport

This one I covered briefly in the architecture post, but it's worth restating because it's the kind of decision you'd skip if you were optimizing for code volume.

Bombie's live preview shows your layout at mobile (~375px), tablet (~768px), and desktop (~1280px) widths. The naive approach is to wrap the preview in a <div> and set its CSS width to the target value:

<div style={{ width: 375 }}>
  <Preview />
</div>
Enter fullscreen mode Exit fullscreen mode

This visually shrinks the preview, but it lies about responsive behavior. MUI's Grid uses useMediaQuery under the hood, which reads window.innerWidth. The <div> is 375px but the window is still 1280px or whatever your laptop is. xs={12} md={6} stays at one column because MUI thinks it's on a desktop.

The fix is an iframe:

<iframe ref={previewRef} style={{ width: '375px', height: '100%' }} />
// inside the iframe: render the preview tree, which now sees its own window object
Enter fullscreen mode Exit fullscreen mode

The iframe has its own window, its own document, and its own viewport. useMediaQuery reads that window. Grid breakpoints fire correctly. Hover and focus styles work. Modals (Dialog) render attached to the iframe's body. Everything you'd want from "what does this look like on mobile" actually behaves like mobile.

The cost is real:

  • Bootstrapping the iframe means injecting the stylesheet and MUI providers (the theme, the cache) into its document.
  • Communicating from the outer app to the inner iframe uses postMessage or a shared module — you can't just pass props across the boundary.
  • The iframe takes a few hundred milliseconds to come up on a cold load.

In Bombie, the iframe receives the JSON tree via postMessage from the outer toolbar, and the preview-side bootstrap script runs render-preview.js on whatever tree it most recently received. The MUI cache and theme are injected on first load. Resize is just setting the iframe's width style.

I'd absolutely do this again. The honest viewport is worth the setup cost — especially for a tool whose entire pitch is "see what this looks like." Anything that ships responsive previews without an iframe is either accepting a lie or doing a lot of work to fake it with useMediaQuery overrides; either way the iframe is cheaper.

Things that aren't lessons (yet)

A few things in the repo are still rough and I'm holding off on calling them lessons until I've solved them properly:

  • Upload / Download JSON. The buttons exist in the toolbar but they're stubs. The serialization story is straightforward (it's JSON.stringify(tree)), but the user-facing flow — what filenames, what happens on a malformed paste, how to show diff between current state and uploaded state — needs more thought than I've given it.
  • Bundle size. MUI is heavy. Bombie's bundle is fine for an experiment but it's not lean. npm run analyze produces a bundle-report.html and the obvious targets (date pickers loaded eagerly, icon barrel imports) are still there.
  • TypeScript. Mentioned in the architecture post. Worth doing, not yet done.

If any of these resonate as something you'd like to tackle, the repo's open and PRs are welcome.

Wrapping up the series

The four posts together cover what Bombie is, how it's structured, how to extend it, and the operational decisions behind it:

  1. Introducing Bombie — what and why
  2. How I Built a Visual UI Builder for React with a JSON-Driven Tree — architecture
  3. Add Your Own Component to Bombie in 5 Edits — extension tutorial
  4. Lessons from Building Bombie — this post

The underlying ideas — UI as a JSON tree, two renderers from one source of truth, schema-driven property editor, iframe for honest viewports — are reusable past Bombie itself. If you're building any kind of visual editor or layout tool, the JSON-tree-plus-two-renderers split has been the single highest-leverage decision in the codebase.

Bombie itself is an experiment, not a product. But the patterns are not.

If you've read this far: try the live demo, star the repo if it was useful, and open an issue or a PR if you'd like to see something different. The whole point of writing this series was to make the project legible enough that other people could contribute to it.


Links

Top comments (0)