A JavaScript PDF editor lets users open a PDF in the browser and change it, then hand the modified file back. Almost every tutorial on the subject builds the same thing on top of pdf-lib. That is one of two possible architectures, and it is the wrong one for roughly half the people who search for it.
The fork is not a library preference. It decides what your users can do, and it is very expensive to change later. This post covers both paths, with working code for each, and a rule for picking.
The two architectures
A PDF is a container of objects: page dictionaries, content streams, font programs, form field definitions, annotation objects, and a cross-reference table pointing at all of them. Any editor has to decide what it does with that structure.
Patch the objects. Load the file, find the object you want, modify it, write the file back out – usually as an incremental update appended to the original bytes. The original file survives intact. This is what pdf-lib, PDF.js overlays, and the document-processing SDKs (Nutrient, Syncfusion, Apryse) do.
Parse into layers. Read the content streams, work out what they draw, and rebuild the page as typed objects – text runs, images, vector paths, tables – on a canvas. The user edits those. On export you generate a new PDF from the scene. This is what design tools do, and it is what Polotno's PDF import does.
Neither is a lesser version of the other. They fail in opposite directions:
| Patch the objects | Parse into layers | |
|---|---|---|
| Fill a form field | Native | Field becomes plain text |
| Digital signature | Preserved | Invalidated |
| Annotations, comments | Real PDF annotation objects | Flattened artwork |
| Reflow a paragraph | Very hard | Native |
| Restyle, rebrand, re-layout | Not really possible | Native |
| Byte-for-byte fidelity of untouched content | Guaranteed | Best-effort re-render |
The last row is the one people underestimate. Patching guarantees that anything you did not touch comes out identical, because you never rewrote it. Parsing re-renders the whole page, so fidelity is only as good as the parser.
Path one: patching with pdf-lib
If your users need to fill in a form, stamp a page, or drop a signature image at fixed coordinates, this is the shorter road.
import { PDFDocument, StandardFonts } from 'pdf-lib';
const pdfDoc = await PDFDocument.load(await file.arrayBuffer());
// Fill an existing AcroForm field
const form = pdfDoc.getForm();
form.getTextField('customer_name').setText('Ada Lovelace');
// Or stamp text at absolute coordinates
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const [page] = pdfDoc.getPages();
page.drawText('PAID', { x: 420, y: 60, size: 24, font });
const bytes = await pdfDoc.save();That is genuinely most of it. The catch is what "editing" means here. drawText paints glyphs at a coordinate; it has no idea a paragraph exists. There is no reflow, no "make this heading bigger and push everything down", no changing a font across the document. You are annotating a fixed page, not editing a document.
Building a UI on top makes that limitation visible fast. You render the page with PDF.js, position HTML inputs over the field rectangles, and write values back through pdf-lib on save. It works well for forms. It stops working the moment a user wants to move something.
Path two: parsing into layers
If your users want to redesign the document – change copy, swap a logo, recolour it, re-lay-out a page – you need the content as editable objects.
import { pdfToJson } from '@polotno/pdf-import';
import { createStore } from 'polotno/model/store';
const json = await pdfToJson({ pdf: await file.arrayBuffer() });
const store = createStore({ key: 'YOUR_API_KEY' });
store.loadJSON(json);
await store.waitLoading();json is a design schema: pages containing text, image, svg, line, and table elements, each with explicit coordinates. Text comes back as real paragraphs with alignment and mixed weights, ruled grids come back as table elements with editable cells, and bullet lists come back as one text element with real <ul> markup rather than glyphs faked with spaces.
Which means an edit is an ordinary property change, not a coordinate calculation:
store.pages.forEach((page) => {
page.children
.filter((el) => el.type === 'text' && el.text.includes('{{name}}'))
.forEach((el) => el.set({ text: el.text.replace('{{name}}', customer) }));
});Wire the same store to the editor UI and your users get the canvas directly:
import { PolotnoContainer, SidePanelWrap, WorkspaceWrap } from 'polotno';
import { Toolbar } from 'polotno/toolbar/toolbar';
import { SidePanel } from 'polotno/side-panel';
import { Workspace } from 'polotno/canvas/workspace';
export const PdfEditor = () => (
<PolotnoContainer style={{ height: '90vh' }}>
<SidePanelWrap><SidePanel store={store} /></SidePanelWrap>
<WorkspaceWrap>
<Toolbar store={store} />
<Workspace store={store} />
</WorkspaceWrap>
</PolotnoContainer>
);Export regenerates a PDF from the scene. Use the vector path when you want selectable text and print-ready output:
import { jsonToPDFBlob } from '@polotno/pdf-export/browser';
const blob = await jsonToPDFBlob(store.toJSON());store.saveAsPDF() is the raster alternative – each page flattened to an image, pixel-identical to the canvas. Vector export supports CMYK, spot colours, bleed, crop marks, and PDF/X-4 or PDF/X-1a; see PDF export. Both run client-side, so the file never leaves the browser. The same functions run under Node if you want this on a server.
You can try the parse step on your own file, with no code, in the PDF to JSON tool – it runs the same pdfToJson() in your browser.
What each path cannot do
Worth being blunt, because both limits are architectural rather than backlog items.
Patching cannot give you design editing. The object model preserves structure precisely because it does not interpret it. A content stream that positions 400 glyphs individually is, to pdf-lib, 400 positioned glyphs – not a sentence you can rewrite.
Parsing cannot give you forms or signatures. Once you have converted the page into a scene graph, the AcroForm dictionaries and annotation objects are gone; export writes a fresh file. A digital signature signs a byte range, so any regeneration invalidates it – signing fundamentally requires appending to the original bytes, which a converter cannot do. Scanned PDFs are the other hard case: no text layer means nothing to parse, so run OCR first. The full list is on the PDF editor SDK page.
If you need both, you need both pipelines. That is a real answer some products arrive at, at the price of two document models to keep in sync.
How to choose
One question settles it: does the output need to still be the input file?
If yes – signed contracts, compliance archives, filled government forms, anything where an auditor cares about the original bytes – patch the objects. Use pdf-lib if your needs are modest, a document SDK if you need annotation and signature workflows.
If no – the PDF is a starting point and the user is producing something new – parse into layers. Brochures, flyers, menus, packaging, catalogues, certificates, ad creative, anything a designer originally made and someone now needs to change without reopening Illustrator.
A useful tell: if your users say "fill this in", patch. If they say "change this", parse.
FAQ
Can I edit a PDF entirely in the browser?
Yes, on both paths. pdf-lib and @polotno/pdf-import both run client-side with no upload, which also means no server CPU and no privacy conversation with your customers.
Why does my imported PDF look slightly different?
Because parsing re-renders the page rather than preserving it. Embedded font subsets, ligature-heavy scripts, and complex transparency groups are the usual causes of a shift. Test with your own documents before committing, and pass an onWarning callback to pdfToJson() so you can tell a clean import from one that silently dropped content.
Can I edit a scanned PDF?
Not as text. A scan has no text layer, so a parser returns image elements and nothing to edit. Run OCR with a dedicated tool first, then import the text-augmented file.
Does editing invalidate a digital signature?
On the parsing path, always – you are generating a new file. On the patching path, an incremental update can preserve an existing signature, which is exactly why signature workflows live there.
Which frameworks does this work with?
The Polotno store is plain JavaScript with React components on top, so React and Next.js are the shortest path, and Vue, Svelte, Angular, or vanilla JS work by mounting the store yourself. The import and export packages have no UI dependency at all and run equally well under Node.
Related reading
- PDF editor SDK – the round trip, in full, with honest limits.
- JavaScript PDF libraries compared – pdf-lib, PDFKit, jsPDF, pdf.js, Puppeteer, and where each fits.
- Polotno SDK vs pdf-lib – the two architectures, head to head.
- PDF generation API – producing new PDFs from data rather than editing existing ones.
