I love building fun little projects, but recently I’ve been disheartened by the landscape of “AI website builder” dev tools. There are a million of them, and there’s no real indication of what separates one from the other. Instead of trying to combat that, I decided to become part of the problem: I built bolt.new using bolt.new, and then used my bolt.new clone to make another bolt.new.
My original goal was to give myself a span of 2 hours, kick off the process of making bolt.new clones, and see how many levels deep I could get within the window. This turned out to be a huge overestimation of my skills in vibe coding -- it took me close to 8 hours to even get the first level of bolt.new clone working, and by the end of a roughly 10 hour span I was only 2 levels deep.
Also, in an attempt to do a little more "building in public," I decided that I would record myself and my screen for the whole process. I thought it would be fun to try to scrap together a Youtube video out of this. Instead, I've now got 10+ hours of footage that mostly consists of my staring blankly at my screen while I try to figure out which level of virtual file system I'm supposed to be working in.
The whole thing was a fun experience though: I learned a lot about webcontainers, CORS, how bolt.new handles http requests, and tool use with Claude. This doc is a quick little writeup just to show what I did, which problems I ran into, and how the final product turned out. Before I get into that, here is the code for both the first website builder (bolt.new.new) and the second website builder (bolt.new.new.new):
Let’s get into it!
Getting Started
First off, I’ll go over what my rough requirements were. Bolt.new has a lot of features and I wanted this to be more of a quick and dirty mockup rather than an actual attempt at a fully featured alternative. This meant that the only features I wanted to include are the ones necessary to make another website builder from inside the website builder being built. For me, that meant skipping credits (no stripe integration — also no stripe integration integration, ie no stripe integration for any subsequent websites), skipping the actual deployment to netlify (I would just use each site directly within it’s parent website builder), no github integration (instead: just include download code functionality and then download and upload to github manually), and no supabase integration (I could just use local storage for persistence. Also no supabase integration integration).
With all of that scrapped, the core requirements would be:
- Left-hand chat interface (with Claude sonnet 4 as the model)
- Toggle between a file explorer and app preview
- File system with files, directories, and ability to add/remove/rename
- text editor
- preview of the app (running within a webcontainer)
- ability for claude to interact with the filesystem (read from/write to files)
So with that in mind, I opened up bolt.new and got started
It started off pretty solid! It had a top bar with project management / save / deploy / export to github buttons (all with placeholder functionality), then 3 tabs on the main page switching between chat, code, and preview. Immediately I ran into some trouble because even though there was an input for anthropic api key, all the requests to the anthropic api were failing.
Turns out the project structure was just a single react app built with vite — so the requests to the anthropic api were all coming from the frontend and subsequently getting blocked by cors. The fix should be straightforward: in addition to the react app, have a node server running that makes the actual requests to anthropic, then pass all our chats through that. In addition, I moved the anthropic api key into a .env file instead of needing to paste it in.
And this worked! It was my first time running something on multiple ports in bolt.new so it took me a minute to make sure things were up and working correctly (I was very impressed by how easy it was to use the port selector on the preview page). Here’s me reacting to the first functional message:
Also at this point we’re about an hour in (although ~20 minutes of that was me taking a call from my Mom). I was starting to realize that the plan of fitting everything into 2 hours was probably going to fall apart.
So now chat was working, but looking at the preview panel everything was just white. Turns out it was some issue with how we were loading react in the preview component… I never really learned what was going on, but Claude was able to one-shot fix it. The preview showed up, displaying a simple react app with a title and a counter. I then tried to prompt Claude to change the title from “React Builder” to “f” and was VERY excited when it worked.
Then there’s a good bit of footage of me just being excited about it working and playing around with it. This is where my default test for Claude became ‘replace the title with turtle emojis.’ I also did some prompting to reorganize the layout to structure it more similarly to bolt.new.
Turns out it's all fake
At this point, I noticed something very funny. We had an in-app terminal for our website builder, but it only seemed to respond to a few commands. Looking at the code, it seemed it was powered by this function:
const simulateCommand = (cmd: string): string[] => {
const trimmedCmd = cmd.trim();
if (trimmedCmd === 'clear') {
setHistory([]);
return [];
}
if (trimmedCmd === 'npm install' || trimmedCmd === 'npm i') {
return [
'Installing dependencies...',
'',
'added 1247 packages, and audited 1248 packages in 12s',
'',
'156 packages are looking for funding',
' run `npm fund` for details',
'',
'found 0 vulnerabilities'
];
}
if (trimmedCmd === 'npm run dev') {
setIsRunning(true);
setCurrentProcess('dev-server');
return [
'',
'> vite-react-typescript-starter@0.0.0 dev',
'> vite',
'',
' VITE v5.4.8 ready in 1247 ms',
'',
' ➜ Local: http://localhost:5173/',
' ➜ Network: use --host to expose',
'',
' ready - started server on 0.0.0.0:5173'
];
}
...
It was all fake. Turns out it wasn’t just the terminal that was being faked: we weren’t even running a proper react app. Our “react app” was just a snippet of html that was being passed back and forth to Claude. The app wasn’t being served up via a development server: it was just a hardcoded chunk of html.
const htmlContent = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Preview</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
}
#root {
min-height: 100vh;
}
.error-container {
padding: 20px;
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
margin: 20px;
color: #c33;
font-family: monospace;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
try {
${transformedCode}
// Ensure we have an App component
if (typeof App === 'undefined') {
throw new Error('No App component found. Make sure your code exports a default App component.');
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(React.createElement(App));
} catch (error) {
console.error('Preview Error:', error);
document.getElementById('root').innerHTML = \`
<div class="error-container">
<h3>Preview Error</h3>
<p><strong>Error:</strong> \${error.message}</p>
<p><strong>Tip:</strong> Make sure your code exports a default App component and uses valid React syntax.</p>
</div>
\`;
}
</script>
</body>
</html>
`;
}
This would not do! To properly get a website builder going inside this website builder, I wanted to be able to support the same react app + node server setup that we had powering this first level website builder. After some brief googling about how bolt.new / stackblitz works, I learned about webcontainers. This was where I learned that I really had some fundemental misunderstandings about website builders like bolt.new. I always just assumed that there was actual code running on a server somewhere and they were just providing a way to pipe info back and forth to the client.
Turns out I was completely wrong: they run code via webcontainers, sandboxed environments that let you run a virtual filesystem + node runtime directly in the browser. I had also assumed the preview urls (https://zp1v56uxy8rdx5ypatb@ockcb9tra-oci3--5173--96435430.local-credentialless.webcontainer-api.io/
) that show during development were netlify-style hosted apps. Turns out they’re just reverse proxies to the webcontainer running in the browser — which I think is the way that our “frontend” react webcontainer app, running in an iframe, and our “backend” node server are able to communicate (someone feel free to correct me if I’m wrong).
Very cool!
Moving to Cursor
I started prompting in bolt.new to try to get my website builder switched over from fake react app to a real react app served up from a webcontainer. This gives me a whole bunch of weird errors off the bat, and while trying to get bolt.new to debug it I start getting “Anthropic API is overloaded” errors. Here’s where I start cheating a little bit by switching to cursor.
I then had a funny bug where (I think) my ad blocker was stopping the lucide ‘key’ icon from loading because apparently files named ‘key.js’ look like ads. I also spent some time debugging some issues with the in-browser terminal (which was no longer fake and now acted as an actual terminal for the webcontainer).
I found out that the reason the webcontainers weren’t working is that my vite config was missing some headers necessary as per the webcontainer docs. This fixed the initialization issues, so the webcontainer succesfully got up and running — however, the live preview was still failing. The preview area was just plain white and the chrome devtool console had a strange error about
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "text/html"
This is the part where I really go into what I like to call an “LLM debugging death spiral.” It’s basically a loop where I run into a confusing bug, can’t figure out what it’s being caused by because I don’t understand the codebase well enough, I try to get an LLM to fix it, then the LLM further complicates the codebase and the cycle continues.
My hunch was that I had messed something up in my vite configuration and was serving up an html file when I should have been serving up a javascript file. The correct flow should have been:
- my project files exist in the webcontainer file system
- the iframe requests ‘/’ from the webcontainer url
- the vite dev server in the webcontainer handles the request, serves up index.html
- index.html has a script tag that requests main.tsx (from the webcontainer)
- main.tsx runs and hydrates the react app
I assumed that when we went to request main.tsx, we were somehow accidentally getting back html, and I had no idea why. This was about 3 hours into the process. I tried so many different configurations to try to debug this: I served up regular html files, tried renaming main.tsx, looked through all the requests in the network tab (not closely enough though!), added a whole separate debug log window, and finally after 2 hours figured out the issue: the main.tsx file was just getting completely erased from the filesystem, meaning that the dev server had nothing to serve up when main.tsx was requested, defaulting to index.html, which was causing the weird html error. Why was main.tsx getting erased? Turns out there was this shallow merge logic:
javascript const mergedFiles = { ...getDefaultProjectFiles(), ...fileSystemTree };
Which when combined with the webcontainer nested way of representing files:
const files = {
// This is a file - provide its path as a key:
'package.json': {
// Because it's a file, add the "file" key
file: {
// Now add its contents
contents: `
{
"name": "vite-starter",
"private": true,
// ...
},
"devDependencies": {
"vite": "^4.0.4"
}
}`,
},
},
};
Was just erasing all files that were deeper than root level (my main.tsx file was within a ‘src’ directory). I’m still not totally sure why we needed the merging logic, but cursor was able to swap this out with a deep merging function and everything worked correctly. Here’s me explaining the whole thing to myself after figuring it out:
I took a little while after this to further iron out some file system issues (some files weren’t showing up in the UI / were getting deleted. Cursor ended up slapping together some kind of convoluted file system logic that will come up again in a little bit, but for now, things were working.
My App Starts Editing Itself
Now that we had our webcontainer working, I decided to retry the chat functionality. It was working previously, but our approach was a lot different now. Before we were sending a single code snippet back and forth to Claude. Now we were managing an entire virtual file system in a webcontainer and we needed Claude to be able to view, edit, and manage all of those files. I had cursor modify the node server file to instead push everything through a text-editor endpoint: the frontend would supply the endpoint with a list of file names, the endpoint would then use Claude’s text editor tool, then manage responses from Claude, viewing/editing files as needed. I modified my chat component slightly to show all the tool usage steps for visibility. Everything looked correct — the view command was getting the correct file contents, and the edit command seemed to be correctly formatted — but no edits were actually getting applied to my files.
Here’s where I start vibe coding way too hard and things get really bad. I had cursor try to figure out why edits weren’t getting applied and it made some fixes. I go to test the chat out in the app, submit my first message, and the app immediately crashes.
Turns out IT WAS USING THE ACTUAL FILE SYSTEM TO READ/WRITE FILES. When Claude requested to rewrite App.tsx, our app responded by editing its own source code. Here’s me staring in disbelief as I try to piece together what just happened.
I reverted everything back to the point where changes weren’t getting applied but at least the project wasn’t editing its own source code anymore. I also took a long break at this point because I could recognize that I was doing dumb stuff without actually understanding what the code was doing.
When I came back, I got sucked down an entirely different rabbithole: for some reason whenever I focused on a file other than main.tsx in the file explorer, the preview would just go white. This was also a bug that took me a while to fix, but it ended up being an issue with a useEffect and an out of date state variable — when switching away from main.tsx, there was an issue where the app still though the selected file was main.tsx even after displaying the contents of the new file, so it thought main.tsx had been edited and was overwriting the main.tsx file, causing the preview to go blank. Luckily this was a pretty straightforward fix. I also got rid of all the confusing file merging logic while debugging this.
After fixing that up, I finally got back to the text editing issues and realized something that really should have been obvious the whole time: the node server that was communicating with Claude had no way of seeing the virtual file system on the frontend! No wonder it wasn’t updating the files. This whole thing really turned out to be as simple as piping the changes back to the frontend from the backend. We could access the webContainerManager from the backend so all we had to do was something like
webContainerManager.mountFiles(updatedWebContainerFiles);
at the end of our Claude editing and everything WORKED! I was very excited.
So now we had a functional chatbot, file editor, and webcontainer serving a react app complete with a terminal and a preview window. The next step was getting a node server running in addition to the react app so that we could make requests to the anthropic api (in our bolt.new.new.new). This actually all went very smoothly: cursor was able to add in a node server, modify the package.json dev script to concurrently run the frontend and backend. There was a slight hiccup with it trying to change the webcontainer connection string, but within 10 minutes we were up and running with a nice bolt.new style port selector in the UI.
Also everything had been using Claude 3.5 up until this point so I switched it to Claude 4 and had it do Simon Willison’s ‘pelican riding a bicycle svg’ trick to test that it was still working. I was delighted with the result.
Then I did some kind of random fixes to get things ready for the inner website builder. npm install was taking ~30 seconds on each page reload so I tried to cache that (cursor suggested storing it in indexedDB), and that seemed to work. In addition, I persisted the virtual file system to local storage so it wouldn’t be erased on each reload. I also tried to get a github integration working but I realized I didn’t actually want to deal with setting up a github app, so instead I just made a download code button and then would use that to push code to github from my machine if needed.
Back to Bolt
Then it was back to bolt.new for the final stretch: actually building the website builder inside the website builder that we built!
I was honestly pretty happy with the first iteration:
Sadly, the requests to the anthropic api were NOT going through. I debugged this for a while and after a few conversations with ChatGPT, I think I figured out what was happening. I don’t think webcontainers are supposed to be able to make http requests to external servers. I’m pretty sure what’s actually happening is that when you make an http request from a node server in bolt.new, bolt.new is intercepting it, making the actual request for you via some proxy server, then forwarding the response back to you, letting you interact with outside servers while still being sandboxed in the browser. This mean that requests from server.js in our bolt.new.new app were getting correctly proxied by bolt.new, but our bolt.new.new app was doing nothing to proxy the requests coming from server.js in our bolt.new.new.new app.
I’m actually a bit confused about this though, because there WERE requests trying to be made to anthropic here — they were just being blocked by CORS. So I guess the idea is that if you don’t do interception of requests, they do actually get made by the browser — they’re just subject to CORS restrictions? I’m still a little iffy on what happened here and if anybody has answers I would love to hear about it.
Anyway, what I decided to do was just deploy a separate express server that allowed all origins for CORS, then just took in a request with a url in the body, then forwarded the request to the url with the body included (excluding the url), then returned the response. Then I swapped out the Anthropic api url inside my bolt.new.new.new node server code.
And with that… IT WORKED!!!
After lots of dumb bugs and rabbitholes, the AI website builder inside an AI website builder built with an AI website builder WAS FUNCTIONAL!
Thank you for reading!