If you've ever searched for a biodata format online, you know the drill: sketchy templates, watermarked PDFs, or forms that ask you to sign up before letting you download anything containing your name, birth date, and family details. When I set out to build BiodataKit, a tool for generating a biodata for marriage document, the first architectural decision wasn't which framework to use — it was whether to use a backend at all. Spoiler: I didn't.
This post is a breakdown of how BiodataKit compiles fully custom, multi-layout documents entirely inside the browser, with zero server round-trips, zero stored PII, and zero database.
Architecture Overview: The Ephemeral Data Pipeline
The core question I kept asking myself was: why spin up Postgres, an ORM, auth, and a storage bucket just to shuffle around names, addresses, photos, and family details for the ninety seconds it takes someone to fill out a form and hit download?
For a document generator, persistence is a liability, not a feature. Every row you store is a row you have to secure, back up, and eventually explain in a breach disclosure. So the pipeline looks like this:
Input → form state lives in memory (React state / a reducer, nothing else)
Transform → state renders directly into a live-preview DOM node
Output → that DOM node gets rasterized to PNG/PDF client-side
Cleanup → all references, object URLs, and state are discarded on refresh or navigation
Nothing ever leaves the tab unless the user explicitly clicks "Download." That single constraint gives you three things for free: zero cloud database costs, instant user trust (you can tell people their data never hits a server, and mean it), and a genuinely simpler ops story — there's no backend to page you at 2 AM.
Deep Dive 1: Managing Schema-Agnostic Dynamic Inputs
The trickiest part isn't the export — it's the form. A biodata document isn't a fixed schema. Users want to add custom fields (a "Rashi" or "Gotra" field for astrological/cultural context), delete defaults they don't need, and reorder entire sections. That means you can't model the form as a flat object with hardcoded keys. You need an array-of-objects schema with stable IDs, mutated immutably.
Here's a simplified version of the reducer I use:
type FieldType = "text" | "date" | "textarea" | "select";
interface BiodataField {
id: string; // stable, generated once (crypto.randomUUID())
label: string;
value: string;
type: FieldType;
isCustom: boolean; // user-injected vs. default field
}
type FieldAction =
| { type: "ADD_FIELD"; payload: Omit<BiodataField, "id"> }
| { type: "UPDATE_FIELD"; payload: { id: string; value: string } }
| { type: "REMOVE_FIELD"; payload: { id: string } }
| { type: "REORDER_FIELDS"; payload: { fromIndex: number; toIndex: number } };
function fieldsReducer(state: BiodataField[], action: FieldAction): BiodataField[] {
switch (action.type) {
case "ADD_FIELD":
return [...state, { id: crypto.randomUUID(), ...action.payload }];
case "UPDATE_FIELD":
return state.map((f) =>
f.id === action.payload.id ? { ...f, value: action.payload.value } : f
);
case "REMOVE_FIELD":
return state.filter((f) => f.id !== action.payload.id);
case "REORDER_FIELDS": {
const next = [...state];
const [moved] = next.splice(action.payload.fromIndex, 1);
next.splice(action.payload.toIndex, 0, moved);
return next;
}
default:
return state;
}
}
The key insight: because every field carries a stable id independent of its array position, reordering and deleting never causes React to misattribute state to the wrong — a bug that's brutally easy to introduce if you key your list by array index instead.
Deep Dive 2: The Browser Export Engine (DOM → Canvas → PDF/PNG)
This is where most "just use html2canvas and call it a day" tutorials fall apart in production. The live preview is a real DOM tree — styled with CSS, using web fonts, clip-path for circular vs. rounded-rectangle profile photos, and dynamic color palette overrides via CSS custom properties. Rasterizing that faithfully takes real work.
The two problems that bit me hardest:
Retina blur — if you rasterize at 1x scale, everything looks fine on a standard display and soft/blurry on any Retina or high-DPI screen.
External image + SVG serialization — tags pointing to external or blob URLs, and inline SVG icons, silently fail to render on canvas if you don't wait for them to fully decode first.
Here's a trimmed-down version of the actual export helper:
async function exportNodeToImage(node, filename = "biodata.png") {
// 1. Wait for every image inside the node to finish decoding.
const images = Array.from(node.querySelectorAll("img"));
await Promise.all(
images.map((img) =>
img.complete ? Promise.resolve() : new Promise((res) => (img.onload = img.onerror = res))
)
);
// 2. Scale for High-DPI displays instead of trusting default 1x rendering.
const scale = Math.max(window.devicePixelRatio || 1, 2);
const canvas = await html2canvas(node, {
scale,
useCORS: true, // needed for cross-origin profile images
backgroundColor: "#ffffff",
logging: false,
});
// 3. Convert canvas to a blob, not a base64 string, to avoid huge memory spikes.
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
// 4. Critical: revoke the object URL after the download fires.
setTimeout(() => URL.revokeObjectURL(url), 1000);
}, "image/png");
}
For PDF export, the same canvas gets handed to jsPDF, sized against the canvas's pixel dimensions divided by the scale factor — otherwise your PDF page size balloons to match the 2x/3x raster resolution instead of the actual document size.
The Quirks: High-DPI Scaling, Memory Leaks, and Mobile Layout Challenges
A few production lessons worth stealing:
devicePixelRatio isn't optional. Hardcode scale: 1 and every MacBook, iPhone, and modern Android device produces a visibly soft export. I clamp it with Math.max(devicePixelRatio, 2) so even non-Retina users get a crisp minimum, without going overboard to 3x/4x on already-high-DPI devices (which tanks canvas generation speed for no visible gain).
URL.createObjectURL leaks are real. If a user generates five documents in one session (trying different templates, tweaking a typo), and you never call URL.revokeObjectURL, each blob stays pinned in memory for the life of the tab. On low-memory mobile devices this is enough to crash the tab during a long editing session. Always pair createObjectURL with a revokeObjectURL cleanup, ideally right after the download fires or on component unmount.
Mobile keyboards break your live preview. When a virtual keyboard opens on a phone, it doesn't resize the viewport the way you'd expect — it just covers half the screen. If your live-preview panel is positioned with 100vh assumptions, it gets shoved off-screen or squashed. Switching to 100dvh (dynamic viewport height) and deferring the preview re-render until the input blurs (rather than on every keystroke) fixed both the layout jank and a real performance problem: re-rasterizing a styled DOM tree on every keypress on a mid-range Android phone is not free.
Wrapping Up
The bigger theme here: a huge number of "utility" web apps — resume builders, invoice generators, biodata makers, certificate creators — don't actually need a database at all. They need a well-modeled client-side state shape and a solid export pipeline. Skipping the backend isn't a limitation; for PII-adjacent tools, it's arguably the more responsible default.
Curious what the community thinks: for tools that handle personal data but don't need to persist it across sessions, should "no backend" be the default assumption rather than the exception? Where's the line where you'd actually reach for a database?
Top comments (2)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.