Framework integrations
React
Every example calls initKernstra() from one small kernstra.ts module that owns the SDK's options. Kernstra.init() needs your license and starts the SDK once per page; a second call with the same options (a remount, a new pdfUrl, React's StrictMode) gets the same promise back, and a call with other options rejects with INVALID_ARGUMENT, which is why one module holds them. Importing the module runs nothing, so it is safe in server-rendered code and on pages that never show a PDF; call it inside an effect. The key is import.meta.env.KERNSTRA_LICENSE: add envPrefix: ['VITE_', 'KERNSTRA_'] to your Vite config so browser code can read KERNSTRA_ variables. What does need care is what an effect puts in the container before it awaits: a loader (PDFViewer.showLoadingIndicator) belongs to the run that placed it, and StrictMode's first run is canceled while its document is still opening. Remove the loader in the cleanup too, as this example does, or that first spinner lingers over the viewer the second run builds until the canceled run's document finishes opening. A host that draws its own toolbar reads toolChanged for which button is pressed — it reports 'selectText' at rest, from the first paint — and panelChanged for its own Pages button; Bring your own chrome has the pattern, PANEL_TOOL_NAMES included.
// kernstra.ts — owns the SDK's options. Nothing runs until the first // call; every later call gets the same promise. import { Kernstra } from '@kernstra/pdf-web-sdk'; export const initKernstra = () => Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE }); // PdfEditor.tsx import { useEffect, useRef } from 'react'; import { PDFDocument, PDFViewer, SDKMode } from '@kernstra/pdf-web-sdk'; import { initKernstra } from './kernstra'; export default function PdfEditor({ pdfUrl }: { pdfUrl: string }) { const containerRef = useRef<HTMLDivElement>(null); useEffect(() => { let canceled = false; let viewer: PDFViewer | null = null; let doc: PDFDocument | null = null; const container = containerRef.current!; const removeLoader = PDFViewer.showLoadingIndicator(container); async function load() { await initKernstra(); const opened = await PDFDocument.fromUrl(pdfUrl); // Unmounted while loading (StrictMode's first run, a route change): // nothing will clean this up later. if (canceled) { void opened.close(); return; } doc = opened; viewer = new PDFViewer(container, doc, { mode: SDKMode.FULL, toolbar: { print: true, download: true }, }); viewer.fitToWidth(); await viewer.whenReady(); } // The loader goes with the first outcome: ready, a failure, or the // cleanup below (a viewer destroyed before it is ready rejects // `whenReady()` with ABORTED, which is no error to report). load().then(removeLoader, (err: unknown) => { removeLoader(); if (!canceled) { console.error(err); } }); return () => { canceled = true; removeLoader(); viewer?.destroy(); void doc?.close(); }; }, [pdfUrl]); return <div ref={containerRef} style={{ width: '100%', height: '100vh' }} />; }
Vue 3
The same lifecycle with onMounted / onBeforeUnmount. The unmounted flag closes a document that finished opening after the component went away, since nothing would clean it up later.
<template> <div ref="container" style="width: 100%; height: 100vh" /> </template> <script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from 'vue'; import { PDFDocument, PDFViewer, SDKMode } from '@kernstra/pdf-web-sdk'; import { initKernstra } from './kernstra'; const props = defineProps<{ pdfUrl: string }>(); const container = ref<HTMLDivElement>(); let viewer: PDFViewer | null = null; let doc: PDFDocument | null = null; let unmounted = false; onMounted(async () => { await initKernstra(); const opened = await PDFDocument.fromUrl(props.pdfUrl); // Unmounted while loading: nothing will clean this up later. if (unmounted) { void opened.close(); return; } doc = opened; viewer = new PDFViewer(container.value!, doc, { mode: SDKMode.FULL, toolbar: { print: true, download: true }, }); viewer.fitToWidth(); }); onBeforeUnmount(() => { unmounted = true; viewer?.destroy(); void doc?.close(); }); </script>
Angular
A component with a viewChild container that opens the document in afterNextRender, which runs only in the browser (also under server rendering, ng new --ssr), and tears it down in ngOnDestroy. The Angular CLI's builder bundles the package as it is: nothing to mark external, no binary to copy. Load the component lazily (a loadComponent route or @defer): the SDK is over the CLI's default initial-bundle budget, so ng build fails if it lands in the main bundle. For the render worker, copy it through angular.json and pass its URL. kernstra.ts reads the key from environment.kernstraLicense: run ng generate environments once and fill both files at build time with --define.
// src/app/pdf-editor.ts import { Component, ElementRef, OnDestroy, afterNextRender, viewChild } from '@angular/core'; import { PDFDocument, PDFViewer, SDKMode } from '@kernstra/pdf-web-sdk'; import { initKernstra } from './kernstra'; @Component({ selector: 'app-pdf-editor', template: '<div #container style="width: 100%; height: 100vh"></div>', }) export class PdfEditor implements OnDestroy { private readonly container = viewChild.required<ElementRef<HTMLDivElement>>('container'); private viewer: PDFViewer | null = null; private doc: PDFDocument | null = null; private destroyed = false; constructor() { // Browser only: afterNextRender never runs during server rendering. afterNextRender(() => void this.open()); } private async open() { await initKernstra(); const opened = await PDFDocument.fromUrl('/document.pdf'); // Destroyed while loading: nothing will clean this up later. if (this.destroyed) { void opened.close(); return; } this.doc = opened; this.viewer = new PDFViewer(this.container().nativeElement, this.doc, { mode: SDKMode.FULL, }); this.viewer.fitToWidth(); } ngOnDestroy() { this.destroyed = true; this.viewer?.destroy(); void this.doc?.close(); } } // angular.json, projects.<app>.architect.build.options: copy the render worker "assets": [ { "glob": "**/*", "input": "public" }, { "glob": "render.worker.js", "input": "node_modules/@kernstra/pdf-web-sdk/dist", "output": "assets" } ] // src/environments/environment.ts and environment.development.ts // (created by `ng generate environments`); serve or build with // ng serve --define "KERNSTRA_LICENSE='$KERNSTRA_LICENSE'" declare const KERNSTRA_LICENSE: string; export const environment = { kernstraLicense: KERNSTRA_LICENSE }; // src/app/kernstra.ts import { Kernstra } from '@kernstra/pdf-web-sdk'; import { environment } from '../environments/environment'; export const initKernstra = () => Kernstra.init({ license: environment.kernstraLicense, workerUrl: '/assets/render.worker.js', });
Svelte
The same lifecycle in Svelte 5: props from $props(), the container bound with bind:this, and onMount returning the cleanup (an async function cannot return one, so the work runs in an inner async function). The component opens pdfUrl once; key it with {#key pdfUrl} to switch documents.
<script lang="ts"> import { onMount } from 'svelte'; import { PDFDocument, PDFViewer, SDKMode } from '@kernstra/pdf-web-sdk'; import { initKernstra } from './kernstra'; // Opens pdfUrl once: wrap it in {#key pdfUrl} to switch documents. let { pdfUrl }: { pdfUrl: string } = $props(); let container: HTMLDivElement; onMount(() => { let destroyed = false; let viewer: PDFViewer | undefined; let doc: PDFDocument | undefined; (async () => { await initKernstra(); const opened = await PDFDocument.fromUrl(pdfUrl); // Destroyed while loading: nothing will clean this up later. if (destroyed) return opened.close(); doc = opened; viewer = new PDFViewer(container, doc, { mode: SDKMode.FULL, toolbar: { print: true, download: true }, }); viewer.fitToWidth(); })().catch(console.error); return () => { destroyed = true; viewer?.destroy(); void doc?.close(); }; }); </script> <div bind:this={container} style="width: 100%; height: 100vh"></div>
Next.js
The package imports cleanly on the server (nothing touches window until Kernstra.init() or a viewer runs) but a viewer is browser-only. Render the component on the client alone: load it with ssr: false, and call initKernstra() inside an effect, never during render. kernstra.ts only defines a function, so importing it anywhere is safe. In the App Router, next/dynamic with ssr: false is allowed only in a client component, so put it in a file that starts with 'use client' and render that from the page; in the Pages Router a page may call it directly. PdfEditor is the React component above. In kernstra.ts read process.env.KERNSTRA_LICENSE, which env in next.config.ts exposes to the browser bundle: the key ships to the browser by design. The render worker: put render.worker.js under public/ and pass workerUrl: '/render.worker.js'.
// app/report/PdfEditorClient.tsx — ssr: false needs a client component 'use client'; import dynamic from 'next/dynamic'; export const PdfEditorClient = dynamic(() => import('./PdfEditor'), { ssr: false }); // app/report/page.tsx — a server component renders the client wrapper import { PdfEditorClient } from './PdfEditorClient'; export default function ReportPage() { return <PdfEditorClient pdfUrl="/report.pdf" />; } // app/report/kernstra.ts — safe to import anywhere; call it inside an effect. // The Get started lib/kernstra.ts plus workerUrl: keep one of them per app. import { Kernstra } from '@kernstra/pdf-web-sdk'; export const initKernstra = () => Kernstra.init({ license: process.env.KERNSTRA_LICENSE ?? '', workerUrl: '/render.worker.js', }); // next.config.ts — add env to the config you have import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // …your existing options env: { KERNSTRA_LICENSE: process.env.KERNSTRA_LICENSE }, }; export default nextConfig;
Nuxt
Give the component the .client.vue suffix, which keeps it out of server rendering, and keep the Vue example's onMounted / onBeforeUnmount pair. In Nuxt the module that owns init is a composable: it reads the key with useRuntimeConfig().public.kernstraLicense, declared in nuxt.config.ts as runtimeConfig: { public: { kernstraLicense: '' } } and filled from NUXT_PUBLIC_KERNSTRA_LICENSE (.env in development, which Nuxt reads instead of .env.local; the server's environment in production). Call it in setup and await what it returns once mounted. Catch a failed load: uncaught, Nuxt replaces the whole page with its error page.
<!-- app/pages/report.vue --> <template> <PdfEditor pdf-url="/report.pdf" /> </template> <!-- app/components/PdfEditor.client.vue --> <template> <div ref="container" style="width: 100%; height: 100vh"></div> </template> <script setup lang="ts"> import { ref, onMounted, onBeforeUnmount } from 'vue'; import { PDFDocument, PDFViewer, SDKMode } from '@kernstra/pdf-web-sdk'; // Nuxt composables run in setup, not in a lifecycle hook. const initKernstra = useKernstra(); const props = defineProps<{ pdfUrl: string }>(); const container = ref<HTMLDivElement>(); let viewer: PDFViewer | null = null; let doc: PDFDocument | null = null; let unmounted = false; async function load() { await initKernstra(); const opened = await PDFDocument.fromUrl(props.pdfUrl); // Unmounted while loading: nothing will clean this up later. if (unmounted) { void opened.close(); return; } doc = opened; viewer = new PDFViewer(container.value!, doc, { mode: SDKMode.FULL }); viewer.fitToWidth(); } onMounted(() => { load().catch(console.error); }); onBeforeUnmount(() => { unmounted = true; viewer?.destroy(); void doc?.close(); }); </script> // app/composables/useKernstra.ts import { Kernstra } from '@kernstra/pdf-web-sdk'; export const useKernstra = () => { const { kernstraLicense } = useRuntimeConfig().public; return () => Kernstra.init({ license: kernstraLicense }); };
SvelteKit
The Svelte component above, unchanged, rendered from a route: onMount never runs on the server, and kernstra.ts only defines a function, so importing it during server-side rendering runs nothing. Keying it by the route parameter mounts a fresh viewer when a client navigation changes the id. The key is import.meta.env.KERNSTRA_LICENSE, with envPrefix: ['VITE_', 'KERNSTRA_'] in your Vite config; on SvelteKit 3 you can instead declare it in src/env.ts (KERNSTRA_LICENSE: { public: true, static: true }) and import it from $app/env/public. SvelteKit 3 replaced the $lib alias with #lib.
<!-- src/routes/documents/[id]/+page.svelte --> <script lang="ts"> import { page } from '$app/state'; // SvelteKit 2: '$lib/PdfEditor.svelte' import PdfEditor from '#lib/PdfEditor.svelte'; </script> {#key page.params.id} <PdfEditor pdfUrl={`/api/documents/${page.params.id}.pdf`} /> {/key}
webpack 5
Webpack 5 emits the worker through its asset modules, no loader configuration needed. The engine and the other binaries come from the asset store the license names, as everywhere else, or are self-hosted under a license that allows it. Four dynamic requests in the SDK's loader print harmless "Critical dependency" warnings, which the dev server shows as an overlay: silence them with ignoreWarnings: [{ module: /@kernstra\/pdf-web-sdk/, message: /critical dependency/i }] in webpack.config.js. process.env in TypeScript needs @types/node. A webpack 4 build cannot parse the package's exports.
import { Kernstra } from '@kernstra/pdf-web-sdk'; await Kernstra.init({ // webpack.config.js (webpack 5.103+): dotenv: { prefix: ['KERNSTRA_'] } // reads .env.local; older: new webpack.EnvironmentPlugin(['KERNSTRA_LICENSE']) license: process.env.KERNSTRA_LICENSE, // Emitted as an asset; the URL is the emitted file's. workerUrl: new URL('@kernstra/pdf-web-sdk/render-worker', import.meta.url).href, });
S3 presigned URL upload
Hide the built-in Print and Download, add a toolbar action, and upload the export with uploadTo(). It resolves to the fetch Response and does not reject on an HTTP error status, so check response.ok.
const viewer = new PDFViewer(container, doc, { toolbar: { print: false, download: false, actions: [ { id: 'submit', label: 'Submit Form', icon: 'done', variant: 'primary', onClick: async () => { const presignedUrl = await fetchPresignedUrl(); // your API const exporter = viewer.createExporter(); const res = await exporter.uploadTo(presignedUrl, { method: 'PUT', headers: { 'Content-Type': 'application/pdf' }, }); if (!res.ok) { showError(res.status); // uploadTo does not reject on an HTTP error return; } showSuccessNotification(); }, }, ], }, });
Read-only viewer
A reader: no tools, no undo; form fields show their values and take no input. Or keep a mode's tools and only freeze the form with forms.readOnly.
// A reader: no tools, no undo; form fields show their values and take no input. const viewer = new PDFViewer(container, doc, { mode: SDKMode.VIEW_ONLY }); // Or keep a mode's tools and only freeze the form: const reviewer = new PDFViewer(container, doc, { mode: SDKMode.FULL, forms: { readOnly: true }, });
Minimal viewer (no controls)
No toolbar, no navigation bar, no Pages panel: the pages alone.
const viewer = new PDFViewer(container, doc, { toolbar: { enabled: false }, navigation: { enabled: false }, sidebar: { pages: false }, });
Programmatic export
Obtain the exporter from viewer.createExporter(): it carries what the viewer holds, the form values and signatures the user entered. Each method takes flatten, invalidateSignatures, pages, onProgress and signal; a flatten left out comes from the viewer's export option.
// Get as Blob for custom handling const blob = await viewer.createExporter().getBlob({ flatten: true }); // Get as ArrayBuffer const buffer = await viewer.createExporter().getArrayBuffer(); // Get as base64 string const base64 = await viewer.createExporter().getBase64(); // Download with custom filename await viewer.createExporter().download('filled-form.pdf', { flatten: true });
Form field pre-population
Values are read and set through doc.forms, with or without a viewer. While a viewer is open they go through its fields: shown at once, on its undo stack, in its export, and formFieldChanged fires once per applied field.
// Discover field names/types const fields = await doc.forms.fields(); console.log(fields.map((f) => ({ name: f.name, type: f.type, exportValue: f.exportValue }))); // Programmatic autofill — through the viewer while it is open const result = await doc.forms.setValues({ 'topmostSubform[0].Page1[0].f1_01[0]': 'Jane Doe', 'topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]': '1', // checkbox by export value }); console.log(result.applied, result.rejected); // Live values (same source the exporter uses) const values = await doc.forms.getValues(); // Track progress viewer.on('formFieldChanged', ({ field }) => { console.log(`Field "${field.name}" changed to "${field.value}"`); }); // Jump to a field viewer.focusField('topmostSubform[0].Page1[0].f1_02[0]');
Loading indicator
PDFViewer.showLoadingIndicator(container, options?) shows a loading spinner before a viewer exists and returns a cleanup function. options.loader is a LoaderConfig and options.locale a LocaleConfig: pass the ones you give the viewer. A loader put in the container stays over the viewer until you remove it, so "show the loader, open, construct, await viewer.whenReady(), hide" is the whole sequence.
// Show the loader while the document opens and the first page draws const loader = { text: 'Opening your document…' }; // same object as the viewer's `loader` const removeLoader = PDFViewer.showLoadingIndicator(container, { loader }); try { const doc = await PDFDocument.fromUrl(pdfUrl); const viewer = new PDFViewer(container, doc, { loader }); await viewer.whenReady(); // the spinner stays over the page until here } finally { removeLoader(); // ready or failed: take the spinner down }
Driving the SDK from React
The shape most hosts need: render the viewer, and separately run your own buttons and forms against the document. No part of this reaches into the SDK's DOM. commit() deactivates the Edit Text tool, so re-activate it before further edits, and treat every block id from before a commit as stale.
import { useEffect, useRef, useState } from 'react'; import { PDFDocument, PDFViewer, SDKMode } from '@kernstra/pdf-web-sdk'; import { initKernstra } from './kernstra'; export default function PdfEditor({ pdfUrl }: { pdfUrl: string }) { const containerRef = useRef<HTMLDivElement>(null); const [handles, setHandles] = useState<{ doc: PDFDocument; viewer: PDFViewer } | null>(null); useEffect(() => { let canceled = false; let viewer: PDFViewer | null = null; let doc: PDFDocument | null = null; async function load() { await initKernstra(); const opened = await PDFDocument.fromUrl(pdfUrl); if (canceled) { void opened.close(); return; } doc = opened; viewer = new PDFViewer(containerRef.current!, doc, { mode: SDKMode.FULL }); setHandles({ doc, viewer }); } void load(); return () => { canceled = true; setHandles(null); viewer?.destroy(); void doc?.close(); }; }, [pdfUrl]); // Replace a field's text wherever it appears, from your own UI. async function fillTotal(amount: string) { if (!handles) return; const { viewer } = handles; if (viewer.activeTool !== 'editText') { await viewer.activateTool('editText'); } const editText = viewer.tools.editText; const blocks = await editText.listBlocks(viewer.currentPageIndex); const target = blocks.find((b) => b.text.startsWith('Total:')); if (target) { await editText.setText(target.id, `Total: ${amount}`); await editText.commit(); // also leaves the tool } } // Assemble pages, then hand the bytes to your backend. async function submit() { if (!handles) return; const { doc } = handles; await doc.pages.delete(doc.pages.count - 1); // drop the instructions page await doc.security.protect({ ownerPassword: 'internal', permissions: { modify: false } }); await fetch('/api/submit', { method: 'PUT', body: (await doc.save()).slice() }); } return ( <> <div ref={containerRef} style={{ width: '100%', height: '100vh' }} /> <button onClick={() => void fillTotal('$1,299.00')} disabled={!handles}> Fill total </button> <button onClick={() => void submit()} disabled={!handles}> Submit </button> </> ); }
Keeping a viewer in step
Every mutating call of the document API announces itself. A viewer attached to the same document reloads, re-renders, and pushes an undo entry. Nothing extra is needed to wire that up: attaching the document is enough.
await doc.pages.insert({ position: { after: 0 } }); await viewer.undo(); // the blank page is gone again