Adding a Text Watermark to Every PDF Page with pdf-lib — Opacity, Position, and Diagonal Rotation in a Next.js API Route

javascript dev.to

Adding a watermark to a PDF sounds simple — stamp some text on every page. But doing it correctly means handling font measurement for accurate centering, applying opacity so the content stays readable, rotating text for the classic diagonal stamp, and supporting multiple position options.

Here's how the PDF Watermark tool at Ultimate Tools works under the hood — using pdf-lib for server-side processing and PDF.js for a live canvas preview.


Architecture: Server Route + Live Preview

The feature has two parts:

  1. Live preview — rendered client-side using PDF.js. As the user changes text, position, font size, or opacity, the canvas redraws immediately. No network request.
  2. Final processing — handled server-side via a Next.js API route using pdf-lib. When the user clicks "Apply Watermark", the PDF is sent to /api/watermark-pdf/, processed, and returned as a download.

This split keeps the preview fast while ensuring the actual watermark is embedded correctly in the PDF structure.


The API Route

// app/api/watermark-pdf/route.ts
import { PDFDocument, StandardFonts, rgb, degrees } from 'pdf-lib';

export const runtime = 'nodejs';

export async function POST(req: NextRequest) {
  const formData = await req.formData();

  const file = formData.get('file') as File;
  const rawText = (formData.get('text') as string) ?? 'CONFIDENTIAL';
  const position = (formData.get('position') as WatermarkPosition) ?? 'center';
  const fontSize = Math.max(12, Math.min(96, Number(formData.get('fontSize') ?? 48)));
  const opacity = Math.max(0.05, Math.min(1, Number(formData.get('opacity') ?? 0.3)));
Enter fullscreen mode Exit fullscreen mode

The text is sanitised before use to strip control characters:

function sanitiseText(text: string): string {
  return text.replace(/[\x00-\x1F\x7F]/g, '').slice(0, 200).trim();
}
Enter fullscreen mode Exit fullscreen mode

Loading the PDF and Embedding the Font

const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer, {
  ignoreEncryption: false,
});

const font = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();
Enter fullscreen mode Exit fullscreen mode

ignoreEncryption: false ensures password-protected PDFs throw an error rather than silently producing a broken output.


Per-Page Position Calculation

Each page in a PDF has its own dimensions (not all pages in a PDF are the same size), so we calculate coordinates per page:

for (const page of pages) {
  const { width, height } = page.getSize();

  const textWidth = font.widthOfTextAtSize(text, fontSize);
  const textHeight = font.heightAtSize(fontSize);

  let x: number, y: number;
  let rotate = degrees(0);

  switch (position) {
    case 'top-left':
      x = 40;
      y = height - textHeight - 30;
      break;
    case 'top-right':
      x = width - textWidth - 40;
      y = height - textHeight - 30;
      break;
    case 'bottom-left':
      x = 40;
      y = 30;
      break;
    case 'bottom-right':
      x = width - textWidth - 40;
      y = 30;
      break;
    case 'center':
    default:
      x = width / 2 - textWidth / 2;
      y = height / 2 - textHeight / 2;
      rotate = degrees(45);
      break;
  }
Enter fullscreen mode Exit fullscreen mode

Key points:

  • pdf-lib uses a bottom-left originy = 0 is the bottom of the page, y = height is the top. This is the opposite of CSS.
  • The center position rotates the text 45° (diagonal stamp) and positions it in the middle of the page.
  • font.widthOfTextAtSize() and font.heightAtSize() give us the exact text dimensions for the chosen font and size — important for accurate centering.

Drawing the Watermark Text

  page.drawText(text, {
    x,
    y,
    size: fontSize,
    font,
    color: rgb(0.5, 0.5, 0.5),  // mid-grey
    opacity,
    rotate,
  });
}
Enter fullscreen mode Exit fullscreen mode

The opacity value ranges from 0.05 to 1.0. For a typical "CONFIDENTIAL" stamp, 0.3 (30%) is readable but doesn't obscure the underlying content.


Returning the Modified PDF

const pdfBytes = await pdfDoc.save();
const outputName = file.name.replace(/\.pdf$/i, '') + '_watermarked.pdf';

return new NextResponse(new Uint8Array(pdfBytes), {
  status: 200,
  headers: {
    'Content-Type': 'application/pdf',
    'Content-Disposition': `attachment; filename="${outputName}"`,
    'Content-Length': String(pdfBytes.byteLength),
  },
});
Enter fullscreen mode Exit fullscreen mode

Live Canvas Preview (Client-Side)

The preview runs in a useEffect whenever the PDF file or any watermark setting changes:

useEffect(() => {
  if (!file || !canvasRef.current) return;
  let cancelled = false;

  async function renderPreview() {
    const pdfjs = await import('/pdfjs.mjs');
    pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.mjs';

    const arrayBuffer = await file.arrayBuffer();
    const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise;
    if (cancelled) return;

    const pdfPage = await pdf.getPage(1);
    // ... scale to container width, render to canvas ...

    // Then draw watermark overlay on top of the rendered page
    const { text, position, fontSize, opacity } = settings;
    ctx.save();
    ctx.globalAlpha = opacity / 100;
    ctx.fillStyle = '#888888';
    ctx.font = `bold ${scaledFontSize}px Helvetica, Arial, sans-serif`;

    if (position === 'center') {
      ctx.translate(w / 2, h / 2);
      ctx.rotate(-Math.PI / 4);  // 45° diagonal
      ctx.fillText(text, -textWidth / 2, textHeight / 2);
    } else {
      ctx.fillText(text, x, y);
    }
    ctx.restore();
  }

  renderPreview();
  return () => { cancelled = true; };
}, [file, settings]);
Enter fullscreen mode Exit fullscreen mode

The preview uses Canvas 2D's ctx.rotate() and ctx.translate() to mimic the server-side positioning. Note that Canvas rotation is clockwise (-Math.PI / 4 = 45° counter-clockwise), while pdf-lib's degrees(45) is counter-clockwise — so they match visually.

The cancelled flag handles cleanup if the component unmounts or settings change before the async render completes.


Edge Cases

Password-protected PDFs — pdf-lib throws on load with ignoreEncryption: false. The API returns a user-friendly error: "This PDF is password-protected and cannot be watermarked."

Variable page sizes — iterating pdfDoc.getPages() and calling page.getSize() per page handles mixed-size documents (e.g. portrait + landscape pages in one PDF).

Long text overflowtextWidth is measured before positioning. For corner positions, this means the text may still overflow on narrow pages. A production solution would either truncate or scale down the font.


Summary

Concern Solution
Text positioning font.widthOfTextAtSize() + font.heightAtSize() per page
Diagonal stamp degrees(45) with centered x/y
Opacity opacity param on page.drawText()
Live preview Canvas 2D mirrors server-side transform
Cleanup cancelled flag + renderTask.cancel()

The full tool is live at Watermark PDF — no upload, processes in the browser on the client side for preview, server-side for the final PDF.

Read Full Tutorial open_in_new
arrow_back Back to Tutorials