How to Build a Browser-Based PDF Editor Without Moving the Whole App to Your Backend
Document tools are awkward to prototype because the user experience feels browser-native, but the runtime often wants to look like a server.
You need file import, workspace state, template logic, PDF export, and a place to keep the editor code together. Pushing all of that onto your backend too early is expensive. Shoving all of it into one browser bundle is not much better.
The pdf_editor demo splits the problem in two. The outer app stays in the browser and handles launch state, portal embedding, and local draft persistence. The inner editor runtime is copied into BrowserPod and served from there.
Keep the shell outside the editor runtime
The outer app owns the part that should stay browser-local:
- the launch button
- the saved-draft badge
- IndexedDB persistence
- the iframe that embeds the live editor
The code in src/main.js makes that split obvious:
const ui = {
launchButton: document.getElementById("launchButton"),
clearButton: document.getElementById("clearButton"),
console: document.getElementById("console"),
sessionBadge: document.getElementById("sessionBadge"),
portalSection: document.getElementById("portalSection"),
portal: document.getElementById("portal"),
portalText: document.getElementById("portalText"),
};
The shell does not need to become the editor. It only needs to start it, embed it, and keep a draft in the browser between launches.
Copy the editor project into the pod before you run it
When the user launches the editor, the outer app boots BrowserPod, creates the runtime directories, and copies the editor files into /project:
pod = await BrowserPod.boot({ apiKey: import.meta.env.VITE_BP_APIKEY });
await new Promise((resolve) => setTimeout(resolve, 500));
const terminal = await pod.createDefaultTerminal(ui.console);
await pod.createDirectory("/project");
await pod.createDirectory("/project/public");
await pod.createDirectory("/project/session");
for (const filePath of POD_FILES) {
await copyFile(pod, filePath);
}
The project list is small and explicit:
const POD_FILES = [
"project/main.js",
"project/package.json",
"project/public/index.html",
"project/public/editor.js",
"project/public/editor.css",
];
Hand the saved draft into the pod at startup
The demo restores a saved draft by writing a session bundle into the pod before npm install and server startup:
if (latestBundle) {
await writeTextFile(
pod,
"/project/session/session.json",
JSON.stringify(latestBundle)
);
}
The browser-side storage helpers live in src/storage.js:
export function getSessionBundle() {
return withStore("readonly", (store) => store.get(KEY));
}
export function saveSessionBundle(bundle) {
return withStore("readwrite", (store) => store.put(bundle, KEY));
}
Start the inner server and embed it through the portal
After the files are in place, the outer app installs dependencies and starts the inner server:
await pod.run("npm", ["install"], { terminal, cwd: "/project", echo: false });
pod.run("node", ["main.js"], { terminal, cwd: "/project", echo: false });
Then it waits for the portal URL and loads the editor inside the iframe:
pod.onPortal(({ url }) => {
portalUrl = url;
ui.portalSection.classList.remove("is-hidden");
ui.portal.src = url;
});
Let the editor runtime own workspace state and export
Inside project/main.js, the Express server owns the editing workspace and handles PDF import and export:
app.post("/api/import/pdf", upload.array("files"), async (req, res) => {
for (const file of req.files) {
const pdf = await PDFDocument.load(file.buffer);
// create assets and pages in the workspace
}
res.json({ workspace: cloneWorkspace() });
});
app.post("/api/export/pdf", async (_req, res) => {
const pdfBytes = await buildExportedPdf();
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", 'attachment; filename="export.pdf"');
res.send(Buffer.from(pdfBytes));
});
Persist changes back into the browser
The editor sends a message to the outer shell whenever the workspace gets dirty:
function notifyDirty() {
window.parent.postMessage({ type: "workspace-dirty" }, "*");
}
The shell listens and saves the session bundle into IndexedDB:
window.addEventListener("message", async (event) => {
if (!event.data || event.data.type !== "workspace-dirty" || !portalUrl)
return;
scheduleSessionSave();
});
async function persistPortalSession() {
const response = await fetch(`${portalUrl}/api/session/export`);
latestBundle = await response.json();
await saveSessionBundle(latestBundle);
}