How to implement PDF signing in React and Node.js

May 2, 2024

Article thumbnail

Introduction

In today’s digital age, electronic signatures play a vital role in various applications, from signing contracts to validating transactions. Integrating signature functionality into web applications is crucial for streamlining processes and enhancing user experience. In this post, I will show you a simple way to add a signature image or any other data to a PDF, with the client side in React and the backend logic in Node.js. To be more precise, I will be using Next.js, that allows both client and server side code.

This signing process does not use any electronic certificate. If you need that functionality, you can use signpdf (formerly node-signpdf)

Structure of the signing process and components

First, lets define the how the process is going to be done:

  1. We show a form to the user, which will contain a canvas to draw the signature and some other text inputs for user information details like name and so on. It will also display the PDF so the user can see it.
  2. When form is submitted, the client transforms the form data into a proper input to be sent to the server.
  3. The server receives the data, applies the transformations and returns the data to the client as an application/pdf buffer.
  4. The client transforms the buffer to a blob file to be able to show the PDF result with all the transformations applied

Given this flow, a few components are needed:

  1. A data structure for pdf transformations to be sent to the server.
  2. The signature pad input. In my case I have used the react-signature-canvas package.
  3. A PDF viewer to show to the user. I have used react-pdf
  4. A server API route with pdf handling capabilities. I have used pdf-lib

With that in mind, lets go step by step constructing this components

Data structure

The structure of the transformations will be very similar to the input that the pdf-lib package expects:

export interface ImageOptions {
  width: number
  height: number
}

export interface TextOptions {
  size: number
}

export type PDFItem<T extends 'image' | 'text'> = {
  page: number
  type: T
  coords: { x: number; y: number }
  data: string
  options: T extends 'image' ? ImageOptions : T extends 'text' ? TextOptions : never
}

As you can see, I have added some type asssertion to automatically infer the proper options type depending on the type of item.

Using the data structure on the server

Now we need to construct the API route that will be called from our client. I am using Next.js but if you are using your own backend with Express or something like that then the code will be very similar.

import { NextApiRequest, NextApiResponse } from 'next'
import { signPdf } from 'lib/pdf/server'
import { PDFItem } from 'lib/pdf/types'

interface Body {
  pdfPath: string
  items: PDFItem[]
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method Not Allowed' })
  }

  try {
    const { pdfPath, items }: Body = req.body

    const host = req.headers['host'] as string
    const protocol = req.headers['x-forwarded-proto'] as string

    const pdfUrl = new URL(
      pdfPath,
      `${protocol || 'http'}://${host}`
    ).toString()

    const signedPdfBytes = await signPdf(pdfUrl, items)

    res.setHeader('Content-Type', 'application/pdf')
    res.setHeader('Content-Length', signedPdfBytes.length)

    // Send the PDF as a Buffer
    res.status(200).send(Buffer.from(signedPdfBytes))
  } catch (error) {
    console.log(error)
    res.status(500).json({ message: 'Internal Server Error' })
  }
}

The code is pretty straightforward as most of the functionality is encapsulated in the signPdf function. The only thing that seems a bit different is that I store the pdf in my Next.js public folder, so we need to construct the absolute URL from pdf path. For example if the pdf receives the path as /contracts/contract.pdf, we need to convert it to an absolute url like http://localhost:3000/contracts/contract.pdf to download it. If you pass an absolute url to the pdf from the client, you can omit this step and directly pass the pdfUrl to the signPdf function, which has the following code:

import { PDFDocument, rgb } from 'pdf-lib'
import { PDFItem, ImageOptions, TextOptions } from './types'

export const signPdf = async (pdfUrl: string, items: PDFItem[]) => {
  // Fetch the PDF from the public directory
  const pdfResponse = await fetch(pdfUrl)
  const pdfBuffer = await pdfResponse.arrayBuffer()
  const pdfBytes = new Uint8Array(pdfBuffer)

  // Load the PDFDocument
  const pdfDoc = await PDFDocument.load(pdfBytes)

  // Process each item (text or image)
  for (const item of items) {
    const page = pdfDoc.getPage(item.page)

    if (item.type === 'image') {
      const image = await pdfDoc.embedPng(item.data)
      const options = item.options as ImageOptions
      page.drawImage(image, {
        x: item.coords.x,
        y: item.coords.y,
        width: options.width,
        height: options.height,
      })
    } else if (item.type === 'text') {
      const options = item.options as TextOptions
      page.drawText(item.data, {
        x: item.coords.x,
        y: item.coords.y,
        size: options.size,
        color: rgb(0, 0, 0),
      })
    }
  }

  // Serialize the PDFDocument to bytes
  const signedPdfBytes = await pdfDoc.save()

  return signedPdfBytes
}

The function just downloads the pdf as a buffer to pass it as parameter to the pdf-lib package. It iterates all the items to apply the transformations and returns the pdf bytes. Simple, right? Now that we have defined all the server side functionality, lets go to the client.

Creating the PDFViewer component

As I mentioned, we want to show the pdf to the user, so I have used the react-pdf library for that. The component uses a carousel to show all the pdf pages if there are more than 1, and a hook to resize it properly depending on the screen size. Here is the code:

import 'react-pdf/dist/esm/Page/AnnotationLayer.css'
import 'react-pdf/dist/esm/Page/TextLayer.css'

import { Fragment, useCallback, useRef, useState } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import { useResizeObserver } from '@wojtekmaj/react-hooks'
import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselPrevious,
  CarouselNext,
} from './Carousel'

interface PDFViewerProps {
  file: string | File | null
}

pdfjs.GlobalWorkerOptions.workerSrc = new URL(
  'pdfjs-dist/build/pdf.worker.min.js',
  import.meta.url
).toString()

export default function PDFViewer({ file }: PDFViewerProps) {
  const containerRef = useRef<HTMLDivElement | null>(null)
  const [containerWidth, setContainerWidth] = useState<number>()
  const [numPages, setNumPages] = useState<number>(0)

  const onResize = useCallback<ResizeObserverCallback>((entries) => {
    const [entry] = entries

    if (entry) {
      setContainerWidth(entry.contentRect.width)
    }
  }, [])

  useResizeObserver(containerRef.current, {}, onResize)

  const onDocumentLoadSuccess = ({ numPages }: { numPages: number }) => {
    setNumPages(numPages)
  }

  return (
    <div className="mx-auto border border-gray-300" ref={containerRef}>
      <Document file={file} onLoadSuccess={onDocumentLoadSuccess}>
        <Carousel>
          <CarouselContent>
            {Array.from(new Array(numPages), (_, index) => (
              <CarouselItem
                key={`page_${index + 1}`}
                className="flex aspect-square items-center justify-center"
              >
                <Page pageNumber={index + 1} width={containerWidth} />
              </CarouselItem>
            ))}
          </CarouselContent>
          {numPages > 1 ? (
            <Fragment>
              <CarouselPrevious />
              <CarouselNext />
            </Fragment>
          ) : null}
        </Carousel>
      </Document>
    </div>
  )
}

The signature pad input

In order to create our form, we need a special input to allow the user to draw the signature. I have implemented it in my usual workflow when creating custom input by using react-hook-form, but you can just adapt the code to your own solution. The signature is stored as a base64 string, which is the data sent to the server. You can see the react-signature-canvas for more information. I have also added a button to reset the signature

'use client'

import { useRef } from 'react'
import SignatureCanvas from 'react-signature-canvas'
import { ControllerRenderProps, FieldValues } from 'react-hook-form'
import { Button } from 'components/Primitives/Button'
import {
  BaseFormInputProps,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from 'components/Primitives/Form'

export default function SignaturePadInput(props: BaseFormInputProps) {
  const { name, control, label, description } = props

  const sigCanvas = useRef<SignatureCanvas>(null)

  const saveSignature = ({
    onChange,
  }: ControllerRenderProps<FieldValues, string>) => {
    if (!sigCanvas.current) return
    const signatureResult = sigCanvas.current
      .getTrimmedCanvas()
      .toDataURL('image/png')
    onChange(signatureResult)
  }

  const resetSignature = ({
    onChange,
  }: ControllerRenderProps<FieldValues, string>) => {
    if (!sigCanvas.current) return
    sigCanvas.current.clear()
    onChange('')
  }

  return (
    <FormField
      control={control}
      name={name}
      render={({ field }) => (
        <FormItem>
          <div className="flex flex-row justify-between items-center">
            <FormLabel>{label}</FormLabel>
            <Button
              type="button"
              variant="outline"
              size="xs"
              onClick={() => resetSignature(field)}
            >
              Reset signature
            </Button>
          </div>
          <FormControl>
            <div className="border border-gray-500 justify-center">
              <SignatureCanvas
                ref={sigCanvas}
                canvasProps={{ width: 300, height: 150 }}
                onEnd={() => saveSignature(field)}
              />
            </div>
          </FormControl>
          <FormDescription>{description}</FormDescription>
          <FormMessage />
        </FormItem>
      )}
    />
  )
}

Making the request to the server

Ir oder to encapsulate the server side request I have created this client function:

import { PDFItem } from './types'

export const modifyPdf = async (pdfPath: string, items: PDFItem[]): Promise<Blob | null> => {
  const res = await fetch('/api/sign-pdf', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ pdfPath, items }),
  })

  if (!res.ok) return null

  const data = await res.arrayBuffer()
  return new Blob([data], { type: 'application/pdf' })
}

It is responsible of transforming the server response to a proper standard Blob, so we dont need to repeat the code in case you use the signing process in different places of your code.

Example usage

Now that all the pieces are constructed, lets join them in a form that will be presented to the user:

import { Fragment, PropsWithChildren, useState } from "react";
import { z } from "zod";
import dynamic from "next/dynamic";
import toast from "react-hot-toast";
import { Button } from "components/Primitives/Button";
import TextInput from "components/Primitives/Inputs/TextInput";
import SignaturePadInput from "components/Primitives/Inputs/SignaturePadInput";
import { modifyPdf } from "lib/pdf/client";
import { PDFItem } from "lib/pdf/types";
import { CloudArrowDownIcon } from "@heroicons/react/24/outline";

const PDFViewer = dynamic(() => import("components/Common/PDFViewer"), {
  ssr: false,
});

// Path will be public/contract.pdf
const contractUrl = "/contract.pdf";

export default function ContractForm() {
  const [signedPdf, setSignedPdf] = useState(null);
  const [signing, setSigning] = useState(false);

  const handleSignPDF = async ({ fullName, signature }) => {
    setSigning(true);

    const currentDate = new Date().toLocaleDateString("es-ES", {
      day: "2-digit",
      month: "2-digit",
      year: "numeric",
    });

    const items: PDFItem[] = [
      {
        page: 0,
        type: "image",
        coords: { x: 90, y: 440 },
        data: signature,
        options: { width: 120, height: 75 },
      },
      {
        page: 0,
        type: "text",
        coords: { x: 260, y: 680 },
        data: currentDate,
        options: { size: 14 },
      },
      {
        page: 0,
        type: "text",
        coords: { x: 190, y: 550 },
        data: fullName,
        options: { size: 12 },
      },
    ];

    const resultPdf = await modifyPdf(contractUrl, items);

    if (!resultPdf) {
      toast.error("An error has ocurred while signing the contract");
      setSigning(false);
      return;
    }

    const contractBlob = URL.createObjectURL(resultPdf);

    // Do whatever with the contract

    setSignedPdf(contractBlob);
    setSigning(false);
  };

  return (
    <div className="flex w-full flex-col flex-start justify-center lg:flex-row gap-5">
      <div className="w-[330px] lg:w-2/3">
        <PDFViewer file={signedPdf || contractUrl} />
      </div>

      <div className="flex flex-col space-y-5">
        <Button asChild>
          <a href={signedPdf || contractUrl} target="_blank" rel="noreferrer">
            <CloudArrowDownIcon className="w-6 h-6 mr-2" />
            {signedPdf ? "Download signed contract" : "Download contract"}
          </a>
        </Button>
        {!signedPdf && (
          <Form>
            <TextInput label="Full name" name="fullName" />
            <SignaturePadInput name="signature" label="Draw your signature here" />
            <Button disabled={signing} onClick={onSubmit}>
              {signing ? "Signing..." : "Sign"}
            </Button>
          </Form>
        )}
      </div>
    </div>
  );
}

© 2024 Angel Hodar