Installation

Install the package, then mount it — in React, Symfony, Laravel, or a plain HTML page.

@storvexa/pdf-builder Chapter 2 of 14
Documentation

Which frameworks this works with

Your backend language does not matter. Python, PHP, Java, Go, Ruby, .NET — they all send HTML, and the builder runs in the browser that receives it. There is no server component, so there is nothing to be compatible with.

What actually decides the integration is whether your front end has a bundler.

Your stackUseNotes
React — Next.js, Vite, CRA, Remix npm package The component directly. Client-side only — see the SSR note below.
Symfony npm + UX React Props come from PHP; no mount code. Or the script tag, if you would rather not add React.
Laravel, Rails, Django, Flask, FastAPI, Spring, .NET, Go, plain PHP pdf-builder.bundle.min.js Two tags and a div. No npm, no build step, no React in your project.
Vue, Svelte, Angular Script tag, or the module build Mount into an element from your component's lifecycle hook, and destroy() on unmount.
WordPress, or any CMS Script tag Enqueue the two files; nothing else changes.
iOS / Android Script tag inside a WebView Works, with real limits — read the next section before committing.
Server-side rendering is not supported, in any framework. The builder measures real elements to decide where pages break, so it needs a live browser layout. In Next.js use dynamic(…, { ssr: false }); elsewhere mount it after the page has loaded.

Vue, Svelte and Angular

// Vue 3 — the same shape works in Svelte's onMount and Angular's ngAfterViewInit. import { onMounted, onBeforeUnmount, ref } from 'vue' import PdfBuilder from '@storvexa/pdf-builder/dist/pdf-builder.standalone.js' import '@storvexa/pdf-builder/dist/pdf-builder.css' const el = ref(null) let editor = null onMounted(() => { editor = PdfBuilder.create(el.value, { onSave: save }) }) // Without this the React root outlives the component and leaks. onBeforeUnmount(() => editor?.destroy())

Phones, tablets and native shells

Stated plainly, because this is worth knowing before you promise it to anyone.

DeviceVerdictWhat actually happens
Desktop / laptop Full Three panels, drag-and-drop, everything.
Tablet Full Below 880px the side panels become slide-over drawers and the canvas takes the width. Touch dragging works — the handles set touch-action: none so a drag is not stolen by the scroller.
Phone Usable, not comfortable The same drawers, but an A4 page is 794px wide and a phone is around 390px, so the canvas scrolls sideways. Editing works; laying out a document does not feel good.
Native shell (Hotwire Native, WKWebView, Android WebView) Same as the browser It is the same web view. Nothing extra is needed, and nothing extra is gained.
The page cannot be scaled to fit a phone, and this is deliberate. A CSS transform on the page element breaks drag-and-drop collision detection — the library would measure the untransformed rectangle and drop blocks in the wrong place. So there is no zoom-to-fit, and there is no zoom control at all. Drag-and-drop is the product; scaling would trade it away for a screen the tool was not designed for.
What to do about it. Treat the builder as a desktop and tablet tool, and give phones the preview — a document reads perfectly on a phone even when it is awkward to lay out there. If your users must build on a phone, put the builder behind a landscape prompt.
It is not a native component. Inside iOS or Android it runs in a web view. There is no SwiftUI or Jetpack Compose version, and there cannot be one while the layout depends on a real browser measuring real elements.

Install

# npm npm install @storvexa/pdf-builder react react-dom # yarn yarn add @storvexa/pdf-builder react react-dom # pnpm pnpm add @storvexa/pdf-builder react react-dom

React is a peer dependency, which means the package deliberately does not bring its own copy. If two copies of React end up in one page, hooks break at runtime with an error that looks nothing like its cause — so the package uses whichever React your application already has.

"Why do I need to install React? It's a React package." A package that bundled React would ship a second copy alongside yours. Peer dependencies are how the ecosystem avoids that. If your app already has React this changes nothing; if it does not, you are installing it once for the whole page.

A React app

The whole integration. There is no provider to wrap and no store to configure.

import { PdfBuilder } from '@storvexa/pdf-builder' import '@storvexa/pdf-builder/styles.css' export function Editor() { return ( <div style={{ height: '80vh' }}> <PdfBuilder onChange={(doc) => console.log(doc)} /> </div> ) }
Do not forget the stylesheet. Without styles.css the builder renders as unstyled markup — usually a tall column of plain text that looks broken rather than unstyled. If your builder looks wrong before you have changed anything, check this first.

Symfony

The cleanest route is Symfony UX React, because props come straight from PHP and there is no mount code to maintain.

1. Install the bridge

composer require symfony/ux-react yarn install && yarn encore dev

2. Register the React components

// assets/bootstrap.js — or wherever you call startStimulusApp() import { registerReactControllerComponents } from '@symfony/ux-react' registerReactControllerComponents(require.context('./react/controllers', true, /\.[jt]sx?$/))
The recipe patches assets/bootstrap.js by that exact name. If your project calls the file something else — stimulus_bootstrap.js, say — the line above is silently never added. The symptom is a <div> that renders with its data-controller attributes intact and nothing inside it, and no error anywhere. Check this file before you check anything else.

3. Write the component

// assets/react/controllers/PdfBuilder.jsx import { PdfBuilder, createDocument } from '@storvexa/pdf-builder' import '@storvexa/pdf-builder/styles.css' export default function ({ locale, currency, saveUrl }) { return ( <PdfBuilder defaultDocument={createDocument({ locale, currency })} onSave={(doc) => fetch(saveUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(doc), })} /> ) }

4. Render it from Twig

<div style="height: 80vh"> {{ react_component('PdfBuilder', { locale: app.request.locale, currency: 'INR', saveUrl: path('my_save_route'), }) }} </div>
Two Symfony-specific traps, both of which cost real hours. The stylesheet. CSS imported from JavaScript is emitted by Encore against the JavaScript entry. If your layout only calls encore_entry_link_tags('css/app'), the builder's CSS is compiled on every build and then never linked. Add encore_entry_link_tags('js/app') as well. The locale. app.request.locale returns en_IN, with an underscore. That is a POSIX locale, not a BCP 47 language tag, and Intl rejects it outright. The package converts it for you, but anything else in your stack that formats dates or numbers will not — pass en-IN where you can.

Plain HTML or PHP — no npm, no build step

If your page is hand-written HTML, or PHP that prints HTML, you need no npm, no bundler and no React. Add a stylesheet and a script, exactly as you would add Bootstrap.

From the CDN

Not published yet. These URLs go live the moment the first version reaches npm. Until then, use the self-hosted route below — it is the same two files, served from your own server.

Nothing to download. Two tags, the same way you add Bootstrap — jsDelivr serves every npm package automatically, so these URLs work the moment a version is published.

<link href="https://cdn.jsdelivr.net/npm/@storvexa/pdf-builder@0.1.0/dist/pdf-builder.css" rel="stylesheet" crossorigin="anonymous"> <script src="https://cdn.jsdelivr.net/npm/@storvexa/pdf-builder@0.1.0/dist/pdf-builder.bundle.min.js" crossorigin="anonymous"></script>
Pin the version. Always. @1.0.0 is a file that can never change. Drop it, or write @latest, and jsDelivr serves whatever is newest — so a release you have never tested can reach your production page without anybody deploying anything.
Where the URL comes from. jsDelivr mirrors npm at cdn.jsdelivr.net/npm/PACKAGE@VERSION/PATH for every public package. There is no CDN to configure and no account to create — publishing to npm is what creates the URL. The same is true of Bootstrap's.

Self-hosted, if you prefer

<link href="/assets/pdf-builder/pdf-builder.css" rel="stylesheet"> <div id="editor" style="height: 80vh"></div> <script src="/assets/pdf-builder/pdf-builder.bundle.min.js"></script> <script> PdfBuilder.create('#editor', { onSave: function (doc) { fetch('/save.php', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(doc) }) } }) </script>

That is it. A global called PdfBuilder appears, with create() on it. No type="module", no import, nothing to install.

With integrity hashes

If you serve the files from a CDN, pin them the way Bootstrap does, so a tampered file is refused rather than executed:

<link href="/assets/pdf-builder/pdf-builder.css" rel="stylesheet" integrity="sha384-50w83KgvGybPTKkmFl9vb+Y/gmxr9Jd34FMvs+0S5n9Rus+zt9vTTtvlEYxDw88N" crossorigin="anonymous"> <script src="/assets/pdf-builder/pdf-builder.bundle.min.js" integrity="sha384-TSCo9421RCNVgvIKyOQAtifSeyYuE1vjucApWsMPO1PQqJKdL0RscPgveh2Q9Ihd" crossorigin="anonymous"></script>
Those hashes belong to one exact build. Rebuild the package and they change — the browser will then refuse to load the file, which is the feature working. Regenerate with openssl dgst -sha384 -binary FILE | openssl base64 -A, or drop the integrity attribute while you are still iterating.

Two builds, and which to use

FileFirst loadUse when
pdf-builder.bundle.min.js
classic script
645 KB gzipped
2.0 MB raw
Simplest. One tag, a global, works everywhere. Everything is inside it, including the PDF renderer.
pdf-builder.standalone.js
module
~250 KB gzipped
856 KB raw
Lighter. Needs type="module" and an import. The 2.3 MB PDF renderer downloads only when someone actually exports.
The naming follows Bootstrap's, and for the same reason. .bundle. means "the dependencies are inside this file". Start with the bundle; move to the module build if the first load matters more than the extra line of code.

Loading a saved document from PHP

<script src="/assets/pdf-builder/pdf-builder.bundle.min.js"></script> <script> var saved = <?= json_encode($row['document_json'] ?: null) ?>; var editor = PdfBuilder.create('#editor', { defaultDocument: saved || PdfBuilder.createDocument({ locale: 'en-IN', currency: 'INR' }), onSave: function (doc) { /* … */ } }); </script>

What you get back

var editor = PdfBuilder.create('#editor') editor.getDocument() // the document right now editor.loadDocument(saved) // replace it — counts as one undo step editor.undo() editor.redo() editor.destroy() // take it off the page

create() takes a CSS selector or an element, and the same options as every other route — onSave, onUploadImage, resolveImageUrl, variables, theme and the rest. Everything in this manual applies.

Already have React and a bundler? Use npm instead. This build carries its own React. On a page that already has one you would ship two — and that is the "Invalid hook call" error in the table below.

The module build, if you prefer it

<link href="/assets/pdf-builder/pdf-builder.css" rel="stylesheet"> <div id="editor" style="height:80vh"></div> <script type="module"> import PdfBuilder from '/assets/pdf-builder/pdf-builder.standalone.js' PdfBuilder.create('#editor', { onSave: save }) </script>
Module scripts do not run from file://. Opening the HTML by double-clicking gives a CORS error. Serve it over http:// — your existing web server is fine. The classic build above has no such restriction.

Which files to copy onto your server

If you use…Copy these from the package's dist/ folder
The classic script pdf-builder.bundle.min.js and pdf-builder.css. Two files, nothing else.
The module build pdf-builder.standalone.js, pdf-builder.css, and the chunks beside them — react-pdf.browser-*.js, CapturedDocument-*.js, capture-*.js, jsx-runtime-*.js. You never reference the chunks yourself; they load on demand. Delete them and PDF export silently stops working.

Put them in one directory — /assets/pdf-builder/, say — and point the two tags at it.

Laravel, Rails, or a bundler

Your app does not have to be a React app. Render an empty element from your template and mount into it from one small entry file. Nothing else on your site becomes React, and the React runtime only loads on that page.

// resources/js/pdf-builder.jsx — one Vite / Mix / esbuild entry import { createRoot } from 'react-dom/client' import { PdfBuilder, createDocument } from '@storvexa/pdf-builder' import '@storvexa/pdf-builder/styles.css' const el = document.getElementById('pdf-builder') const saved = el.dataset.document ? JSON.parse(el.dataset.document) : undefined createRoot(el).render( <PdfBuilder defaultDocument={saved ?? createDocument()} />, )
<!-- Blade / ERB / plain HTML --> <div id="pdf-builder" style="height: 80vh"></div>

Giving it a height

The builder fills its container. If that container has no height you get a builder with no height, which looks exactly like a failed load. This catches almost everyone once.

Do height: 80vh, height: calc(100vh - 64px), or a flex parent that gives it a real size.
Don't Leave the container at height: auto and wonder why the canvas is a sliver.

Install-time gotchas

SymptomCause and fix
"Invalid hook call" at runtime Two copies of React. Check npm ls react. With Vite, add every React entrypoint to optimizeDeps.include so they share one pre-bundle generation — three different ?v= hashes in a stack trace means three generations.
Unstyled markup, no layout styles.css was never imported, or its build output was never linked. See the Symfony note above.
Empty <div>, no console error With Symfony UX React: the component was never registered. With a manual mount: the element did not exist when the script ran — load it as a module, or mount after DOMContentLoaded.
Builder is one pixel tall The container has no height.
Server-side rendering crashes The builder is a browser component — it measures real elements to lay pages out. Render it client-side only: dynamic(…, { ssr: false }) in Next.js.
Package changes do not appear after a rebuild If you install it from a local tarball, Yarn 1 caches it by name+version and keeps serving the first one it saw. Bump the version, or extract the tarball into node_modules yourself.