Capture Signatures in React with React Signature Canvas

Build a reusable signature capture flow with validation and persistence using React Hook Form and Zustand.

·Matija Žiberna·
Capture Signatures in React with React Signature Canvas

⚛️ Advanced React Development Guides

Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.

No spam. Unsubscribe anytime.

I was preparing a lightweight proof of concept that needed a legally acceptable signature field inside a plain React form. The usual canvas examples felt too detached from the realities of controlled forms, persistence, and server submission. After threading together react-signature-canvas, React Hook Form, and a Zustand store, I ended up with a repeatable pattern that you can plug into any project. This guide walks you through building that exact flow end to end.

1. Install Dependencies and Scaffold the Form

Start with any React or Next.js project. Install the packages that handle form state, drawing, and persistence. In a Next.js App Router project, run:

npm install react-hook-form zod @hookform/resolvers zustand react-signature-canvas

With those pieces available, create a basic form shell that will soon host the signature pad.

// File: src/app/signature/page.tsx
'use client'

import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'

const schema = z.object({
  signerName: z.string().min(1, 'Please provide your full name'),
  signature: z.string().min(1, 'A signature is required')
})

type FormValues = z.infer<typeof schema>

export default function SignaturePage() {
  const form = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: {
      signerName: '',
      signature: ''
    }
  })

  const handleSubmit = form.handleSubmit(async (data) => {
    await fetch('/api/signature', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    })
    form.reset()
  })

  return (
    <main className="mx-auto max-w-xl space-y-6 py-10">
      <h1 className="text-3xl font-semibold">Sign the consent form</h1>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div className="space-y-2">
          <label className="block text-sm font-medium">Full name</label>
          <input
            {...form.register('signerName')}
            className="w-full rounded border px-3 py-2"
            placeholder="Jane Doe"
          />
          <p className="text-sm text-red-600">
            {form.formState.errors.signerName?.message}
          </p>
        </div>

        <p className="text-sm text-red-600">
          {form.formState.errors.signature?.message}
        </p>

        <button
          type="submit"
          className="rounded bg-blue-600 px-4 py-2 text-white font-medium disabled:opacity-60"
          disabled={form.formState.isSubmitting}
        >
          {form.formState.isSubmitting ? 'Submitting…' : 'Submit signature'}
        </button>
      </form>
    </main>
  )
}

This component provides a minimal page with form validation ready to plug in the signature field. The submit handler sends both the typed name and the Base64 signature to an API endpoint with JSON payloads. Next we will add a persistence layer so the signature survives route changes or reloads.

2. Persist Form Data with Zustand

Zustand keeps the captured signature accessible outside the form so you can preview or resend it without resketching. Create a small store that syncs the signer name and signature.

// File: src/lib/signature-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'

type SignatureState = {
  signerName: string
  signature?: string
  setSignerName: (value: string) => void
  setSignature: (dataUrl?: string) => void
  reset: () => void
}

export const useSignatureStore = create<SignatureState>()(
  persist(
    (set) => ({
      signerName: '',
      signature: undefined,
      setSignerName: (value) => set({ signerName: value }),
      setSignature: (dataUrl) => set({ signature: dataUrl }),
      reset: () => set({ signerName: '', signature: undefined })
    }),
    { name: 'signature-demo' }
  )
)

The persist middleware writes the values to localStorage, so refreshing the page restores the signature preview immediately. Wiring this store into the form keeps the code organized while still allowing you to replace it with another persistence layer later if needed.

3. Build a Reusable Signature Pad

Now create the component that wraps react-signature-canvas, binds it to React Hook Form, and mirrors state into Zustand.

// File: src/components/signature-pad.tsx
'use client'

import { useEffect, useRef } from 'react'
import SignatureCanvas from 'react-signature-canvas'
import { Controller, useFormContext } from 'react-hook-form'
import { useSignatureStore } from '@/lib/signature-store'

type SignaturePadProps = {
  name: 'signature'
}

export function SignaturePad({ name }: SignaturePadProps) {
  const canvasRef = useRef<SignatureCanvas>(null)
  const { control } = useFormContext()
  const signature = useSignatureStore((state) => state.signature)
  const setSignature = useSignatureStore((state) => state.setSignature)

  useEffect(() => {
    if (signature && canvasRef.current) {
      canvasRef.current.fromDataURL(signature)
    }
  }, [signature])

  return (
    <Controller
      control={control}
      name={name}
      render={({ field }) => (
        <div className="space-y-2">
          <label className="block text-sm font-medium">Signature</label>
          <div className="rounded border bg-white p-2">
            <SignatureCanvas
              ref={canvasRef}
              onEnd={() => {
                const dataUrl = canvasRef.current?.toDataURL()
                field.onChange(dataUrl)
                setSignature(dataUrl)
              }}
              canvasProps={{
                className: 'h-48 w-full cursor-crosshair rounded'
              }}
            />
          </div>
          <div className="flex justify-end gap-2">
            <button
              type="button"
              className="rounded border px-3 py-1 text-sm"
              onClick={() => {
                canvasRef.current?.clear()
                field.onChange(undefined)
                setSignature(undefined)
              }}
            >
              Clear
            </button>
            <button
              type="button"
              className="rounded border px-3 py-1 text-sm"
              onClick={() => {
                const dataUrl = canvasRef.current?.toDataURL()
                if (dataUrl) {
                  field.onChange(dataUrl)
                  setSignature(dataUrl)
                }
              }}
            >
              Save snapshot
            </button>
          </div>
        </div>
      )}
    />
  )
}

The component loads any stored signature when it mounts, captures strokes through the onEnd callback, and forwards the Base64 data to both the form and the store. The clear button resets every place where the signature lives, preventing stale previews or submissions. With this pad in place, the form page can consume it through React Hook Form’s context provider.

4. Connect React Hook Form with the Store and Pad

Update the page component to wrap the form with a FormProvider, bind the signer name field to Zustand, and insert the signature pad.

// File: src/app/signature/page.tsx
'use client'

import { FormProvider, useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useSignatureStore } from '@/lib/signature-store'
import { SignaturePad } from '@/components/signature-pad'

const schema = z.object({
  signerName: z.string().min(1, 'Please provide your full name'),
  signature: z.string().min(1, 'A signature is required')
})

type FormValues = z.infer<typeof schema>

export default function SignaturePage() {
  const store = useSignatureStore()
  const form = useForm<FormValues>({
    resolver: zodResolver(schema),
    defaultValues: {
      signerName: store.signerName,
      signature: store.signature ?? ''
    }
  })

  const handleSubmit = form.handleSubmit(async (data) => {
    await fetch('/api/signature', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(data)
    })
    store.reset()
    form.reset()
  })

  return (
    <main className="mx-auto max-w-xl space-y-6 py-10">
      <h1 className="text-3xl font-semibold">Sign the consent form</h1>
      <FormProvider {...form}>
        <form onSubmit={handleSubmit} className="space-y-4">
          <div className="space-y-2">
            <label className="block text-sm font-medium">Full name</label>
            <input
              {...form.register('signerName', {
                onChange: (event) => store.setSignerName(event.target.value)
              })}
              className="w-full rounded border px-3 py-2"
              placeholder="Jane Doe"
            />
            <p className="text-sm text-red-600">
              {form.formState.errors.signerName?.message}
            </p>
          </div>

          <SignaturePad name="signature" />

          <p className="text-sm text-red-600">
            {form.formState.errors.signature?.message}
          </p>

          <button
            type="submit"
            className="rounded bg-blue-600 px-4 py-2 text-white font-medium disabled:opacity-60"
            disabled={form.formState.isSubmitting}
          >
            {form.formState.isSubmitting ? 'Submitting…' : 'Submit signature'}
          </button>
        </form>
      </FormProvider>
    </main>
  )
}

The store now mirrors every keystroke in the name field and every stroke on the canvas. Submissions send both values to the API and then wipe the persisted data so the pad returns to a clean state for the next signer. The only missing piece is the server-side endpoint that accepts and records the payload.

5. Accept the Signature on the Server

Implement a simple API route to receive the JSON payload. In a Next.js project, place the following handler under the App Router app/api directory.

// File: src/app/api/signature/route.ts
import { NextResponse } from 'next/server'
import { z } from 'zod'

const payload = z.object({
  signerName: z.string(),
  signature: z.string().startsWith('data:image')
})

export async function POST(request: Request) {
  const body = await request.json()
  const result = payload.safeParse(body)

  if (!result.success) {
    return NextResponse.json({ error: 'Invalid payload' }, { status: 400 })
  }

  const { signerName, signature } = result.data

  // Persist to your storage of choice here
  console.log('Captured signature from', signerName)
  console.log('Data URL length', signature.length)

  return NextResponse.json({ status: 'ok' })
}

This handler validates that the name exists and the signature field contains a data URL. Replace the console.log calls with database writes, S3 uploads, or any downstream process you need. Because the payload is already in Base64, it is ready to store or forward immediately.

6. Launch the Signature Collection Page

Wire the page into your routing so users can visit it directly. With Next.js this page already sits at /signature; in a Vite app you would render it inside your router of choice. Start the dev server, visit the page, draw a signature, and confirm that the server logs both the name and the data URL length. Clearing the pad should update both the visual canvas and the persisted state instantly.

Conclusion

We started with a blank React page and ended with a fully functional signature capture flow that lets users sketch a signature, validates it alongside typed fields, preserves it with Zustand, and posts the Base64 data to an API endpoint. You now have a reusable blueprint for adding legally meaningful signatures to any client project. Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

0

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.