Get started · Web SDK

From npm install to an embedded PDF viewer in under a minute.

One package, one call to initialize, one element on your page: the Kernstra Web SDK, a JavaScript PDF viewer and editor, running in the browser. This guide takes you from a blank project to a working viewer with the tools you choose, and shows you where every next step lives.

TypeScript types includedAny framework, or noneDevelopment key is freeDocuments never leave the browser
Before you start

Four things, and you probably have them all.

A current browserThe last two versions of Chrome, Edge, Firefox and Safari on desktop; Safari on iOS and iPadOS, Chrome on Android. On phones, add the viewport meta tag.
Node 20 and an ES2022 buildNode is only for installing and building; the SDK runs in the browser. Set your build target to ES2022 or later: the SDK requires it.
HTTPS, or localhostThe browser gives the SDK what it needs only over HTTPS or on localhost. On plain HTTP, including a phone opening your dev server by its LAN address, init rejects.
An element with a heightThe viewer fills its container, so the container needs a height: 80vh, or 100dvh on phones. Forgetting it is the classic first mistake.
Choose your way in

Two ways to embed the PDF viewer. The same viewer at the end of each.

Pick the one that matches your project. The snippets here are complete files: they copy with one click and run on a development key, which the next section shows you how to get.

TerminalShell
$ npm install @kernstra/pdf-web-sdk
.env.localShell
# Your key, from the console. Swap in the production key later.
KERNSTRA_LICENSE=<your key>
vite.config.tsTypeScript
import { defineConfig } from 'vite';

// Add these to the config you have (keep its plugins).
export default defineConfig({
  // Let browser code read KERNSTRA_* variables. To type it, add
  // interface ImportMetaEnv { readonly KERNSTRA_LICENSE: string }
  // to src/vite-env.d.ts.
  envPrefix: ['VITE_', 'KERNSTRA_'],
  // The SDK needs ES2022 or later, and so does top-level await.
  // Vite 5 to 7 default to older targets; Vite 8's default fits.
  build: { target: 'es2022' },
});
src/viewer.tsTypeScript
import { Kernstra, PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk';

// 1. Once per page load. Checks your key and loads the engine.
await Kernstra.init({
  license: import.meta.env.KERNSTRA_LICENSE,
});

// 2. Open a document. Nothing about it leaves the browser.
const doc = await PDFDocument.fromUrl('/reports/q3-board-pack.pdf');

// 3. Give it an element with a height. Options are optional:
//    here, the ribbon toolbar instead of the default bar.
const container = document.getElementById('viewer')!;
new PDFViewer(container, doc, {
  toolbar: { layout: 'ribbon' },
});
index.htmlHTML
<div id="viewer" style="height: 80vh"></div>
<script type="module" src="/src/viewer.ts"></script>
  • 1
    Initialize once per page. A second call with the same options returns the same promise, so a hot reload or a second viewer costs nothing.
  • 2
    Open from a URL, a File or bytes. A large file can start opening after its first few hundred kilobytes. See Loading documents.
  • 3
    The viewer owns one element. Its interface lives in that container; only its stylesheets and its dialogs sit outside, in the head and the body. It sizes itself from the container, not the window, so it works in a narrow column too. The container must be in the main document, not a shadow root.
No files to host. The rendering engine is not in your bundle: init downloads it once per browser and SDK version, checks it and caches it. Your bundler only sees ordinary JavaScript. Bundlers and hosting has the details.
src/lib/kernstra.tsTypeScript
// The one module that owns the SDK's options. Nothing runs
// until the first call; every later call gets the same promise.
// In vite.config.ts, add envPrefix: ['VITE_', 'KERNSTRA_'].
import { Kernstra } from '@kernstra/pdf-web-sdk';

export const initKernstra = () =>
  Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });
src/components/DocumentViewer.tsxTSX
import { useEffect, useRef } from 'react';
import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk';
import { initKernstra } from '../lib/kernstra';

export function DocumentViewer({ url }: { url: string }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let canceled = false;
    let doc: PDFDocument | undefined;
    let viewer: PDFViewer | undefined;

    (async () => {
      await initKernstra(); // safe on every mount
      const opened = await PDFDocument.fromUrl(url);
      if (canceled) {
        await opened.close();
        return;
      }
      doc = opened;
      viewer = new PDFViewer(ref.current!, doc, {
        toolbar: { layout: 'ribbon' },
      });
    })().catch(console.error);

    return () => {
      canceled = true;
      viewer?.destroy();
      void doc?.close();
    };
  }, [url]); // a new url opens a new document

  return <div ref={ref} style={{ height: '80vh' }} />;
}
lib/kernstra.tsTypeScript
// Owns the SDK's options; nothing runs until the first call.
// In next.config.ts, add to your config so the browser bundle has it:
//   env: { KERNSTRA_LICENSE: process.env.KERNSTRA_LICENSE },
import { Kernstra } from '@kernstra/pdf-web-sdk';

export const initKernstra = () =>
  Kernstra.init({ license: process.env.KERNSTRA_LICENSE ?? '' });
app/documents/[id]/viewer.tsxTSX
'use client';

import dynamic from 'next/dynamic';

// The React tab's component; its ../lib/kernstra import is
// lib/kernstra.ts, which reads process.env.KERNSTRA_LICENSE.
// It needs the browser, so it never renders on the server.
export const DocumentViewer = dynamic(
  () => import('@/components/DocumentViewer').then((m) => m.DocumentViewer),
  { ssr: false, loading: () => <div style={{ height: '80vh' }} /> },
);
app/documents/[id]/page.tsxTSX
import { Suspense } from 'react';
import { DocumentViewer } from './viewer';

type Params = Promise<{ id: string }>;

// New apps prerender a static shell (cacheComponents), so
// params is read inside <Suspense>, not at the top.
export default function Page({ params }: { params: Params }) {
  return (
    <Suspense fallback={<div style={{ height: '80vh' }} />}>
      <Viewer params={params} />
    </Suspense>
  );
}

async function Viewer({ params }: { params: Params }) {
  const { id } = await params;
  return <DocumentViewer url={`/api/documents/${id}`} />;
}
src/lib/kernstra.tsTypeScript
// The one module that owns the SDK's options. Nothing runs
// until the first call; every later call gets the same promise.
// In vite.config.ts, add envPrefix: ['VITE_', 'KERNSTRA_'].
import { Kernstra } from '@kernstra/pdf-web-sdk';

export const initKernstra = () =>
  Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });
src/components/DocumentViewer.vueHTML
<!-- Opens its url once: give it :key="url" to switch documents. -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk';
import { initKernstra } from '../lib/kernstra';

const props = defineProps<{ url: string }>();
const el = ref<HTMLDivElement>();
let doc: PDFDocument | undefined;
let viewer: PDFViewer | undefined;
let unmounted = false;

async function open() {
  await initKernstra();
  const opened = await PDFDocument.fromUrl(props.url);
  if (unmounted) return opened.close();
  doc = opened;
  viewer = new PDFViewer(el.value!, doc, {
    toolbar: { layout: 'ribbon' },
  });
}

onMounted(() => {
  open().catch(console.error); // log a failed load, keep the page
});

onBeforeUnmount(() => {
  unmounted = true;
  viewer?.destroy();
  void doc?.close();
});
</script>

<template>
  <div ref="el" style="height: 80vh" />
</template>
app/composables/useKernstra.tsTypeScript
// Owns the SDK's options. nuxt.config.ts:
//   runtimeConfig: { public: { kernstraLicense: '' } }
// Nuxt fills it from NUXT_PUBLIC_KERNSTRA_LICENSE: in .env for
// nuxi dev (Nuxt skips .env.local), in the server's environment
// in production.
import { Kernstra } from '@kernstra/pdf-web-sdk';

// Call it in setup; await what it returns once mounted.
export const useKernstra = () => {
  const { kernstraLicense } = useRuntimeConfig().public;
  return () => Kernstra.init({ license: kernstraLicense });
};
app/components/DocumentViewer.client.vueHTML
<!-- The .client suffix keeps it out of server rendering. -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk';

const initKernstra = useKernstra(); // auto-imported composable

const props = defineProps<{ url: string }>();
const el = ref<HTMLDivElement>();
let doc: PDFDocument | undefined;
let viewer: PDFViewer | undefined;
let unmounted = false;

async function open() {
  await initKernstra();
  const opened = await PDFDocument.fromUrl(props.url);
  if (unmounted) return opened.close();
  doc = opened;
  viewer = new PDFViewer(el.value!, doc, {
    toolbar: { layout: 'ribbon' },
  });
}

onMounted(() => {
  open().catch(console.error); // log a failed load, keep the page
});

onBeforeUnmount(() => {
  unmounted = true;
  viewer?.destroy();
  void doc?.close();
});
</script>

<template>
  <div ref="el" style="height: 80vh" />
</template>
src/app/kernstra.service.tsTypeScript
import { Injectable } from '@angular/core';
import { Kernstra } from '@kernstra/pdf-web-sdk';
// After `ng generate environments`, both environment files hold:
//   declare const KERNSTRA_LICENSE: string;
//   export const environment = { kernstraLicense: KERNSTRA_LICENSE };
// and the CLI fills it in from your variable:
//   ng serve --define "KERNSTRA_LICENSE='$KERNSTRA_LICENSE'"
import { environment } from '../environments/environment';

// Owns the SDK's options; nothing runs until init() is first called.
@Injectable({ providedIn: 'root' })
export class KernstraService {
  init() {
    return Kernstra.init({ license: environment.kernstraLicense });
  }
}
src/app/document-viewer.tsTypeScript
import {
  Component, ElementRef, afterRenderEffect, inject, input, viewChild,
} from '@angular/core';
import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk';
import { KernstraService } from './kernstra.service';

@Component({
  selector: 'app-document-viewer',
  template: `<div #host style="height: 80vh"></div>`,
})
export class DocumentViewer {
  readonly url = input.required<string>();
  private readonly host =
    viewChild.required<ElementRef<HTMLElement>>('host');
  private readonly kernstra = inject(KernstraService);

  constructor() {
    // Browser only, so server rendering never runs it. Runs again when
    // url changes; the cleanup runs first, and on destroy.
    afterRenderEffect((onCleanup) => {
      const url = this.url();
      const host = this.host().nativeElement;
      let doc: PDFDocument | undefined;
      let viewer: PDFViewer | undefined;
      let canceled = false;
      onCleanup(() => {
        canceled = true;
        viewer?.destroy();
        void doc?.close();
      });
      void (async () => {
        await this.kernstra.init();
        const opened = await PDFDocument.fromUrl(url);
        if (canceled) return opened.close();
        doc = opened;
        viewer = new PDFViewer(host, opened, {
          toolbar: { layout: 'ribbon' },
        });
      })();
    });
  }
}
src/lib/kernstra.tsTypeScript
// The one module that owns the SDK's options. Nothing runs
// until the first call; every later call gets the same promise.
// In vite.config.ts, add envPrefix: ['VITE_', 'KERNSTRA_'].
import { Kernstra } from '@kernstra/pdf-web-sdk';

export const initKernstra = () =>
  Kernstra.init({ license: import.meta.env.KERNSTRA_LICENSE });
src/lib/DocumentViewer.svelteHTML
<script lang="ts">
  import { onMount } from 'svelte';
  import { PDFDocument, PDFViewer } from '@kernstra/pdf-web-sdk';
  import { initKernstra } from './kernstra';

  // Opens its url once: wrap it in {#key url} to switch documents.
  let { url }: { url: string } = $props();
  let el: HTMLDivElement;

  onMount(() => {
    let destroyed = false;
    let doc: PDFDocument | undefined;
    let viewer: PDFViewer | undefined;

    (async () => {
      await initKernstra();
      const opened = await PDFDocument.fromUrl(url);
      if (destroyed) return opened.close();
      doc = opened;
      viewer = new PDFViewer(el, doc, { toolbar: { layout: 'ribbon' } });
    })().catch(console.error);

    return () => {
      destroyed = true;
      viewer?.destroy();
      void doc?.close();
    };
  });
</script>

<div bind:this={el} style="height: 80vh"></div>
src/routes/documents/[id]/+page.svelteHTML
<script lang="ts">
  import { page } from '$app/state';
  // SvelteKit 2: '$lib/DocumentViewer.svelte'
  import DocumentViewer from '#lib/DocumentViewer.svelte';
</script>

<!-- The Svelte tab's two files, unchanged: onMount runs only in the
     browser. The key mounts a fresh viewer when the id changes. -->
{#key page.params.id}
  <DocumentViewer url={`/api/documents/${page.params.id}`} />
{/key}
  • 1
    One module owns init. Each tab’s kernstra.ts (a composable in Nuxt, a service in Angular) holds the SDK’s options and runs nothing until it is called, so importing it is safe on a server. Every later call gets the same promise, so StrictMode, hot reload and a second viewer need no guard.
  • 2
    Destroy on unmount, close what you opened. viewer.destroy() releases the viewer and doc.close() the document, including one that finishes opening after the component is gone. React and Angular reopen on a new url; Vue and Svelte open theirs once, so key them by it to switch documents.
  • 3
    The key from the environment. Every tab reads KERNSTRA_LICENSE the way its framework exposes variables to the browser: envPrefix in Vite, env in Next.js, runtimeConfig in Nuxt (filled from NUXT_PUBLIC_KERNSTRA_LICENSE), --define into environment.ts in Angular. It is not a secret: it ships to the browser, and a production key runs only on the domains it names.
Server rendering. The package imports safely on a server, but a viewer needs the browser. In Next.js, Nuxt and SvelteKit, mount it on the client only, as their tabs show; in Angular, afterRenderEffect runs only in the browser.
Both ways produce the same viewer, with the same 26 tools, the same layouts and the same SDK API underneath. Start in a plain module today and move it into your framework next month without changing a line of viewer code.
Your first key

A development key is free, and it takes a minute.

It runs on any host, your laptop, a CI runner or a preview deployment, as long as the page is on HTTPS or localhost. Every page it renders, prints or exports carries the Kernstra watermark, so development output can never ship by mistake. A free organization’s development key also leaves out reading content through the API (extracting and searching text, form values) and copying text. When you are ready to ship, request a production license in the same console: Kernstra replies within one business day, and the production key runs on your domains.

Step 1Create an accountGoogle, or a sign-in link to your email. No password, no card, no call.
Step 2Save your development keyThe first one comes with your account and is shown once. Make more whenever you like: they are free and never counted.
Step 3Put it in your environmentinit reads it from there, so the production key drops in later without a code change.

Create your free key in the console (opens in a new tab)

What the SDK sends. On each load, a short status check: your key, a one-time random value, a pseudonymous identifier for this browser (name it in your privacy notice) and a few facts about the SDK itself, such as its version and how the load went. That is how production use is counted against your plan; development keys never count. No document, file name, text or field value is ever sent. Development keys are for building and testing, under our Terms of Service.
Line by line

Build a PDF viewer step by step, as you read.

Six short steps. The preview changes with the step you are on, so you can see what each line adds before you write it.

1Initialize once

Start the engine.

One call per page load. It checks your key, then loads the rendering engine and makes sure it is exactly the file this SDK version expects. A browser downloads the engine once and keeps it, so later loads start from the cache. Nothing is on screen yet.

viewer.tsTypeScript
import { Kernstra } from '@kernstra/pdf-web-sdk';

await Kernstra.init({
  license: import.meta.env.KERNSTRA_LICENSE,
});

Kernstra.isInitialized(); // true
Kernstra.version;         // '1.0.0'
2Open a document

Open the file.

From a URL here; other sources are under Loading documents. A document is independent of any viewer: you can read its page count, change its pages or protect it without ever showing it. Once the file is in, opening reads its structure, not every page, so even a large one opens quickly.

viewer.tsTypeScript
import { PDFDocument } from '@kernstra/pdf-web-sdk';

const doc = await PDFDocument.fromUrl(
  '/reports/q3-board-pack.pdf',
);

doc.pageCount; // 48
3Mount the viewer

Put it on the page.

Any element with a height. With no configuration you get the full toolbar, the navigation bar and the page thumbnails. The viewer measures the container, not the window, so it also works inside a narrow column or a split view.

viewer.tsTypeScript
import { PDFViewer } from '@kernstra/pdf-web-sdk';

const container = document.getElementById('viewer')!;
const viewer = new PDFViewer(container, doc);
4Choose the tools

Keep only what this screen needs.

Every tool is a switch, on unless you turn it off. A review screen might want highlight, note, sign and search, and nothing that changes the document's text. Turn the rest off and the toolbar shrinks to fit. Print and download are their own switches.

viewer.tsTypeScript
import { PDFViewer, TOOL_NAMES }
  from '@kernstra/pdf-web-sdk';

// Four tools, plus Select and Pan to read with.
const keep = new Set([
  'selectText', 'pan',
  'highlight', 'note', 'sign', 'search',
]);
const tools = Object.fromEntries(
  TOOL_NAMES.map((t) => [t, keep.has(t)]),
);

// Replaces the viewer from step 3.
const viewer = new PDFViewer(container, doc, {
  tools,
  toolbar: { print: true, download: false },
});
5Listen

Know what the person is doing.

The viewer emits events for navigation, zoom, form fields, signatures and exports. Wire them to your own state, your analytics or your validation. Undo and redo are one history across every tool, and you can drive them from your own buttons.

viewer.tsTypeScript
// setPage, validate, markSigned, undoButton: your code.
viewer.on('pageChanged', ({ pageIndex }) =>
  setPage(pageIndex + 1));
viewer.on('formFieldChanged', ({ field }) =>
  validate(field.name, field.value));
viewer.on('signatureCaptured', ({ pageIndex }) =>
  markSigned(pageIndex));

undoButton.onclick = () => viewer.undo();
6Save

Get the bytes back.

Saving produces a standard PDF with every edit written in, and it happens in the browser. Ask the viewer for the file from your own button, or take over the toolbar's Download button and send it where it belongs.

viewer.tsTypeScript
// Step 4's viewer, with Download back on
// and running your save instead.
const viewer = new PDFViewer(container, doc, {
  tools,
  callbacks: { onDownload: () => void save() },
});

// Everything on screen, every edit written in.
async function save() {
  const res = await viewer
    .createExporter()
    .uploadTo('/api/documents/417'); // a PUT
  if (!res.ok) showSaveFailed();
}
localhost:5173
Nothing on screen yet. The engine is ready.0 / 48 pages
Loading documents

Open a PDF from a URL, a file, a large file, or a locked one.

A document opens the same way whatever its source, and it never leaves the browser to be opened.

From a URL

Any URL your page can fetch. Same-origin needs nothing; cross-origin needs an Access-Control-Allow-Origin header for your page.

TypeScript
const doc = await PDFDocument.fromUrl(
  '/reports/q3-board-pack.pdf',
);
From a file the person picks

Straight from an input or a drop event. The bytes stay in memory on the device.

TypeScript
let doc: PDFDocument | undefined;
let viewer: PDFViewer | undefined;
let latest: File | undefined;

input.addEventListener('change', async () => {
  const file = input.files?.[0];
  if (!file) return;
  latest = file;
  viewer?.destroy(); // one viewer at a time
  viewer = undefined;
  const old = doc;
  doc = undefined;
  await old?.close();
  const opened = await PDFDocument.fromFile(file);
  if (file !== latest) return opened.close(); // a newer pick won
  doc = opened;
  viewer = new PDFViewer(container, doc);
});
Large files, progressively

The first pages open after a small part of the file has arrived, and the rest streams in behind it, 256 KiB at a time. Until all of it is there, people can read and fill in forms; editing waits. The server must answer HTTP range requests, and most do.

TypeScript
const doc = await PDFDocument.fromUrlProgressive(
  '/scans/site-survey-2026.pdf',
  { onProgress: (loaded, total) => setProgress(loaded / total) },
);
107 MB file: open after about 0.2 MB, first page after about 6 MB, not all 107 MB
Password-protected

Pass it as the password option when you open the file. A missing or wrong one rejects with PASSWORD_ERROR: ask in your own dialog, or the SDK’s createPasswordPrompt, and try again.

TypeScript
try {
  doc = await PDFDocument.fromUrl(url, { password });
} catch (e) {
  if (e instanceof SDKError
      && e.code === ErrorCode.PASSWORD_ERROR) {
    askForPassword(); // then open it again
  }
}
Make it yours

Five settings most teams change in the first hour.

Everything on screen is configurable, and everything can be removed. These are the ones that come up first.

TypeScript
new PDFViewer(container, doc, {
  theme: {
    mode: 'auto',            // light · dark · auto
    accentColor: '#2F6BFF',  // your brand color
  },
});

Light, dark, or follow the system.

One accent color reaches every button, focus ring and selection. For deeper changes, every color, radius and font is a design token you can override, and the main regions carry stable class names for placing, sizing or hiding them.

  • No badge or watermark on a production key.
  • Icons can be swapped one by one through the toolbar configuration.
TypeScript
new PDFViewer(container, doc, {
  toolbar: {
    layout: 'ribbon',        // bar · ribbon · rail · none
    groups: ['annotate', 'review', 'pages'],
    actions: [{
      id: 'send',
      label: 'Send for approval',
      icon: 'done',
      variant: 'primary',
      onClick: send,
    }],
  },
  sidebar: { attachments: false },
  navigation: { zoomControls: false },
});

A bar across the top, a ribbon, a rail down the side, or nothing.

Choose which tool groups appear (on the ribbon, in your order) and add your own actions beside them, so the person never leaves the document to do the next thing.

  • On a narrow container, the tools that do not fit fold into a More menu.
  • The sidebar's Pages, Outline, Attachments, Signatures and Comments panels are switches too.
TypeScript
new PDFViewer(container, doc, {
  layout: {
    phone: 'sheet',          // sheet · dock · split · float · none
    tablet: 'rail',          // rail · none
    pointer: 'auto',         // 'coarse' previews touch on a desktop
  },
});

Measured from the container, not the viewport.

In a phone-sized container the toolbar becomes a sheet, a dock, a split control or a floating cluster, and the same tools keep working, including editing text with a finger.

  • On a touch screen every control grows to a 44px target, and controls a mouse reveals on hover are always shown.
  • Breakpoints are yours to override.
TypeScript
new PDFViewer(container, doc, {
  locale: {
    lang: 'de',
    translations: {
      de: {
        'cluster.highlight': 'Markieren', // the toolbar button
        'tool.sign': 'Unterschreiben',
        'common.pageCount': '{n, plural, one {# Seite} other {# Seiten}}',
      },
    },
    // Your own words, whatever the language.
    strings: { 'common.download': 'Save a copy' },
  },
});

Every label is a string you can replace.

The SDK ships English; give it your translations key by key, or rename a tool to match your own vocabulary. Plurals follow each language's own rules.

  • Full keyboard access and screen-reader announcements are on by default.
  • Arabic, Hebrew and Persian documents render and search normally; the viewer's own layout stays left to right.
TypeScript
// No viewer. Just the document.
await doc.pages.insert({ position: { after: 0 } });
await doc.pages.rotate(2, 90);

// Search reads text out, so it needs the extract feature.
const [hit] = await doc.text.search('termination');
if (hit) {
  await doc.annotations.create(hit.pageIndex, {
    type: 'note',
    rect: { x: 500, y: 575, width: 20, height: 20 },
    contents: 'Confirm the response window.',
  });
}

await doc.security.protect({
  ownerPassword, // from your server, not your bundle
  permissions: { modify: false },
});
const bytes = await doc.save();

The SDK API works without any interface on screen.

Pages, annotations, search, security and saving are all callable from your own code. Editing existing text needs a viewer, and so do Add Text, signatures, images and stamps, which are placed through the interface.

  • A viewer attached to the same document re-renders and records undo automatically.
  • The SDK API reference has a matrix of every tool and how much of it is reachable from code.
Bundlers and hosting

Any bundler that reads package exports. No files to host.

The package is ordinary ES2022 JavaScript with TypeScript types. The engine is not part of it: on every load, init makes sure the engine is exactly the file this SDK version expects, so a swapped or truncated file fails with an error instead of running.

Vite 5+one configAdd envPrefix so browser code can read KERNSTRA_LICENSE. Vite 5 to 7 build for an older target by default, so set build.target: 'es2022'; Vite 8's default already fits. Both are in the vite.config.ts sample above.
webpack 5one settingReads the package as it is. Add ignoreWarnings: [{ module: /@kernstra\/pdf-web-sdk/, message: /critical dependency/i }]: four dynamic requests in the SDK's loader warn harmlessly. webpack 4 cannot parse the package and is not supported.
Next.js 13+zero configClient-only import, as in the framework tab. App and Pages routers both work.
Angular 16+lazy-loadHandled by the builder. Load the viewer lazily, with a loadComponent route or @defer: the SDK is about 1 MB gzipped, over the CLI's default initial budget, so ng build fails if it lands in the main bundle. The optional render worker needs an assets entry in angular.json.
Rollup 3+, esbuildzero configBoth read the package’s exports. If you set an esbuild or browserslist target, keep it at ES2022 or later. Parcel 2 also works: it installs the buffer and process polyfills on the first build.
Babel or SWCleave it outIf you transpile node_modules to older JavaScript, exclude this package: the SDK requires ES2022 or later.

Rendering off the main thread

Pages render on the main thread by default. For large documents or slower phones, serve the render worker that ships in the package (dist/render.worker.js) from your own site and pass its URL to init as workerUrl. It is your own copy of a package file, so any license may set it, unlike the engine URLs that only self-hosting licenses may change. It makes no request of its own: it takes the engine from the page, which downloads it once. Viewing renders on the worker under every license, once a progressive file has fully arrived. Under a production license that includes export, with clean output, the worker also follows your edits; under any other license, a development key included, it stops at the first change to the document and pages render on the main thread from then on. Turn it off with performance: { workerEnabled: false }. The worker keeps a second copy of the engine in memory.

src/lib/kernstra.tsTypeScript
import { Kernstra } from '@kernstra/pdf-web-sdk';

// Copy the worker that ships in the package to your static files:
//   cp node_modules/@kernstra/pdf-web-sdk/dist/render.worker.js public/assets/
// With Vite, skip the copy: import workerUrl from
// '@kernstra/pdf-web-sdk/render-worker?url' and pass workerUrl.
export const initKernstra = () =>
  Kernstra.init({
    license: import.meta.env.KERNSTRA_LICENSE,
    workerUrl: '/assets/render.worker.js',
  });

Content security policy

If you set one, these are the directives the SDK needs; the docs' installation guide explains each line. Add the origin of any document you open to connect-src. The render worker's own response needs script-src 'self' 'wasm-unsafe-eval'. With a strict style-src, pass your nonce once and every stylesheet the SDK adds carries it; the policy also refuses style="…" in your own markup, so give the container its height in a stylesheet. Policies that enforce Trusted Types are not supported in 1.0.

Content-Security-Policy directivesHTTP
script-src  'self' 'wasm-unsafe-eval';
worker-src  'self';
connect-src 'self' <the two Kernstra origins your license names>;
img-src     'self' data:;
style-src   'self' 'nonce-<nonce>';
src/lib/kernstra.tsTypeScript
import { Kernstra } from '@kernstra/pdf-web-sdk';

// Or declare it once in <meta property="csp-nonce" nonce="…">.
export const initKernstra = () =>
  Kernstra.init({
    license: import.meta.env.KERNSTRA_LICENSE,
    styleNonce: nonceFromYourServer, // on every stylesheet the SDK adds
  });
What crosses the network: the license status check on load, and, once per browser, the engine on first load and the text-shaping files and fonts on first use of a text tool. No document, file name, text or field value is ever sent. A license confirmed in the last 48 hours keeps working without a watermark if the check cannot get through; a browser that has never had an answer is watermarked until it does.
When something is off

Seven common problems, and the fix for each.

The viewer is as tall as the whole document, or the page scrolls instead of it

The container has no height, so the viewer grows to fit the whole document and the page scrolls instead. Give it one: percent heights need a sized parent, a viewport unit is the reliable choice, and a flex or grid child also needs min-height: 0. Leave its overflow alone.

CSS
#viewer { height: 80vh; }   /* or 100dvh on phones, or a sized flex child */

init rejects with ENGINE_LOAD_FAILED

The engine could not be downloaded or checked. A browser’s first load needs the network. If you set a content security policy, check that it allows 'wasm-unsafe-eval' and the two origins your license names in connect-src, as in Bundlers and hosting.

The whole file downloads before the first page appears

Use fromUrlProgressive: fromUrl always downloads the whole file. Progressive opening needs the server to answer range requests with 206 Partial Content; one that ignores them gets a normal full download. Across origins, CORS must also expose Content-Range, or the file does not open.

HTTP
Access-Control-Expose-Headers: Content-Range, Accept-Ranges, Content-Length

There is a watermark on every page

You are on a development key, which marks every page on screen, in print and in exports. That is by design: switch the environment variable to your production key. On a production key, a watermark means the license could not be confirmed, for 48 hours or ever on this browser: check that your connect-src and any proxy allow its origins. Kernstra.getLicense() says why.

init rejects with LICENSE_REQUIRED

Kernstra.getLicense() reports the reason. no-license: the variable reached the browser empty; check that your bundler exposes it (Vite’s envPrefix, Next.js’s env), restart the dev server after editing .env, and set it where you deploy too. origin-mismatch: a production key runs only on the domains it names; ask for the host, including staging and preview hosts, in a support case, and the key you deployed picks it up by itself once it is added. insecure-context: use HTTPS or localhost.

A tool is missing, or a call rejects with FEATURE_NOT_LICENSED

Each license unlocks features: Edit Text, Add Text, page editing, annotations, forms, export, print, and reading content out through the API (extracting and searching text, form values). A tool outside yours is not available, and its API calls reject. Kernstra.getLicense() lists what yours includes.

Next.js says document is not defined

Something called Kernstra.init() or built a viewer on the server. Load the viewer component on the client only, with dynamic(…, { ssr: false }) as in the Next.js tab, and call init inside an effect. Nuxt and SvelteKit have the same cause; their tabs show their fix.

Where next

You have a viewer. Here is where the rest lives.

Stuck?

An engineer answers. Not a ticket queue.

Send the snippet that is not doing what you expect, and an engineer who works on the SDK replies. Development keys get email support too.

  • Replies within one business dayUrgent cases first, by email and in the console.
  • Send a document, get a diagnosisA file that renders oddly or edits wrongly is a bug we want. Share it privately and we will tell you what we see.
  • ChangelogEvery release is listed with what changed.