Capture Signatures in React with React Signature Canvas
Build a reusable signature capture flow with validation and persistence using React Hook Form and Zustand.

⚛️ Advanced React Development Guides
Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.
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