Skip to main content

Authoring

The primitives, the styles-not-CSS model, composition with ordinary Vue, and definePdf.

A PDF template is an ordinary Vue Single File Component under pdfs/. You get typed props, interpolation, v-if, keyed v-for, slots, and local components. What differs from a page component is the element set and the styling model.

The directory

Templates live in pdfs/**/*.vue. Three sibling directories are reserved and are not registered as documents:

DirectoryPurpose
pdfs/componentsLocal .vue components used by templates
pdfs/assetsLocal PNG/JPEG images (see Images & fonts)
pdfs/fontsLocal TTF/OTF fonts

Discovery is deterministic. Nested paths keep slash registry keys (reports/monthly), and a project template overrides a same-keyed template from an extended layer.

The primitives

The document primitives are available throughout PDF templates and their descendants inside the isolated PDF renderer app:

ComponentRole
PdfDocumentRoot; carries document metadata and outline mode
PdfPageA page with a size, orientation, and padding
PdfViewA flexbox container (the layout workhorse)
PdfTextA text run; the only place text may live
PdfImageA raster image from a local or embedded source
PdfLinkAn external URL or internal #id link annotation
PdfNoteA sticky-note annotation

There is also a full SVG primitive set for vector drawing. Every primitive's props are tabulated in the reference.

Text must live inside a PdfText. Non-whitespace text placed directly under a PdfView or PdfPage fails the render with PDF_TREE_INVALID; Nuxt PDF never silently drops document content.

The prop surface is closed at runtime as well as in TypeScript. Vue can forward undeclared attributes that escape static checking, so the renderer keeps a per-primitive allowlist: an unknown prop, a DOM/event attribute, or a valid PDF prop used on the wrong primitive fails with PDF_TREE_INVALID. There is no silent passthrough to the engine.

Styles are PDF objects, not CSS

The :style prop takes a typed Nuxt PDF style object, not browser CSS. It is a familiar subset of flexbox, the box model, colors, and transforms. It is its own framework-owned system. Nuxt PDF does not re-export the wider, version-dependent React PDF stylesheet types:

vue
<PdfView
  :style="{
    flexDirection: 'row',
    justifyContent: 'space-between',
    alignItems: 'center',
    padding: 12,
    backgroundColor: '#f4f6f4',
    borderRadius: 4,
  }"
>
  <PdfText>Invoice total</PdfText>
</PdfView>

Key differences from CSS:

  • Properties are camelCase (backgroundColor, marginTop), never kebab-case.
  • The default flex direction is column, as in React Native. It is not row.
  • Values are unitless points by default; percentages are strings ('50%').
  • There is no cascade from a global stylesheet. Inheritance flows through the component tree for the properties the engine inherits (font family, size, color), and you set everything else explicitly.

For a reusable style, use satisfies PdfStyle. It checks misspelled keys, unsupported values, and units without widening the object's inferred type:

vue
<script setup lang="ts">
import type { PdfStyle } from '@lupinum/nuxt-pdf'

const base = {
  color: '#18251d',
  fontSize: 11,
  lineHeight: 1.4,
} satisfies PdfStyle

const total = {
  fontWeight: 700,
  textAlign: 'right',
} satisfies PdfStyle
</script>

<template>
  <PdfText :style="[base, isTotal && total]">
    {{ amount }}
  </PdfText>
</template>

Prefer satisfies PdfStyle to as PdfStyle: an assertion can hide an invalid property while satisfies reports it at authoring time. The complete key, value, unit, inheritance, and primitive matrix is in the style reference.

:style also accepts recursively nested arrays. false, null, and undefined entries are filtered and the remaining objects are merged left-to-right:

vue
<PdfText :style="[base, [isTotal && total]]">
  {{ amount }}
</PdfText>

Composition is ordinary Vue

Break a document into components exactly as you would a page. Keep pieces owned by one document under pdfs/components/<document>; reserve the components root for pieces genuinely reused by several documents:

pdfs/components/invoice/InvoiceLine.vue
<script setup lang="ts">
defineProps<{ label: string; amount: string }>()
</script>

<template>
  <PdfView :style="{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 4 }">
    <PdfText>{{ label }}</PdfText>
    <PdfText>{{ amount }}</PdfText>
  </PdfView>
</template>
pdfs/invoice.vue
<script setup lang="ts">
import InvoiceLine from './components/invoice/InvoiceLine.vue'
</script>

<template>
  <PdfView>
    <InvoiceLine
      v-for="line in invoice.lines"
      :key="line.id"
      :label="line.label"
      :amount="line.amount"
    />
  </PdfView>
</template>

v-if, keyed v-for, and slots all work. Invalid primitive nesting (for example a PdfPage inside a PdfText) and DOM-only attributes (class, data-*, aria-*) fail early with a targeted diagnostic naming the template.

Execution model

Each render creates a fresh Vue runtime-core application in Node. Nuxt PDF does not use Vue's server renderer, so the lifecycle is deliberately different from HTML SSR:

  • mount, update, and unmount hooks run in Node;
  • onServerPrefetch is not a PDF data-loading hook;
  • browser globals such as window and document are unavailable; and
  • the PDF app is unmounted after the render, including when authoring fails.

Load request and application data before calling pdf.<template>.render(props), then pass it through typed props. Async setup, top-level await, and defineAsyncComponent are rejected rather than allowing document content to appear late or disappear silently.

Vue reactivity APIs such as ref and computed, plus usePdfPageNumbers, are scope-aware auto-imports in PDF SFCs. Nuxt app-context composables, plugins, app-level provides, global DOM components, and directives are not inherited by the fresh renderer app. Use explicit local imports and typed props. Ordinary provide/inject between ancestors and descendants inside the same PDF tree works normally.

DOM-only rendering concepts do not have PDF equivalents. Teleport and v-show are rejected; use ordinary local composition and v-if instead. definePdf is likewise valid only as one top-level compiler-macro call in a discovered pdfs/*.vue template. If it is called elsewhere, its runtime fallback throws a targeted error.

Dynamic text

Some content is known only during pagination. The page number is the most common example. A PdfText with a render function receives per-page context and returns a string or number:

vue
<PdfText
  fixed
  :render="({ pageNumber, totalPages }) => `Page ${pageNumber} of ${totalPages}`"
/>

render callbacks are synchronous and must return a scalar. They run during layout, once per page the node appears on. Pair render with fixed to repeat the node on every page.

Dynamic text renders with the font's default line spacing; an inherited or explicit lineHeight multiplier is intentionally not applied to it. Apply lineHeight directly to static PdfText nodes. This is a deliberate divergence from upstream React PDF. See Conformance.

definePdf

Each template calls definePdf once to declare render metadata and development-only preview data:

ts
definePdf<Props>({
  title: props => `Report ${props.id}`,
  filename: props => `report-${props.id}.pdf`,
  language: 'en-GB',
  sampleData: { id: 'sample' },
  scenarios: {
    long: { id: 'long-report' },
  },
})

title and filename are a static string or a function of props. A defined title or language overrides the same metadata on PdfDocument on every layout pass; an omitted value leaves the document prop intact. sampleData drives the default preview, and each entry in scenarios becomes a preview tab and a ?scenario= query. Both preview fields are structurally absent from production builds and never appear on the public template handle. Full option semantics are in the definePdf reference.