✨ Strapi MCP is now Generally Available - let your agents manage your Strapi content ✨

Ecosystem16 min read

How to Build an e-Signature Platform with Strapi 5 and React

July 29, 2026
Build an e-Signature Platform with Strapi 5 and React

Electronic signatures are a staple of modern document workflows. The U.S. ESIGN Act defines an electronic signature as any "electronic sound, symbol, or process, attached to or logically associated with a contract or other record, and executed or adopted by a person with the intent to sign the record."

A canvas-drawn signature qualifies if made with intent. Capturing signer identity and storing an audit trail do not themselves make an e-signature valid, but they materially improve the ability to authenticate and enforce it in court. This tutorial builds a platform that does all three.

You'll create a Strapi 5 backend with a custom Collection Type for tracking document signings, a custom route for processing signatures, and a React 19 frontend (scaffolded with Vite) that lets users draw their signature on a canvas, submit it, and see a list of signed documents.

The backend stores signature images in the Strapi Media Library and links them to document entries via the REST API, while the Document Service API remains the primary programmatic interface for content in Strapi v5.

In brief:

  • Define a custom Content-Type in Strapi 5 with enumeration fields, media relations, and reserved-name constraints
  • Create custom routes, controllers, and services using Strapi 5 factories
  • Use the two-step upload pattern required by Strapi 5 (upload file first, then link to entry)
  • Capture a signature on an HTML canvas using signature_pad and convert it to a Blob for upload

Prerequisites

Install these exact versions before starting. The tutorial assumes you're comfortable with JavaScript, React component patterns, and REST APIs.

ToolVersionInstall / Verify
Node.js (LTS)v24.xnode -v → v24.16.0 or similar. Install via nodejs.org or nvm install 24
npmv11.xShips with Node.js 24. npm -v to verify
StrapilatestInstalled via npx create-strapi@latest in the next section
ReactlatestScaffolded with Vite's React template
VitelatestInstalled via npm create vite@latest
signature_pad5.1.3npm install signature_pad@5.1.3
react-signature-canvas1.1.0-alpha.2npm install react-signature-canvas@1.1.0-alpha.2

No third-party API keys or accounts are required. The entire stack runs locally with SQLite.

Setting Up the Strapi Backend

Step 1: Create a New Strapi 5 Project

Open your terminal and run:

npx create-strapi@latest my-esignature-backend --non-interactive --skip-cloud --js

This creates a Strapi project with SQLite as the default database. The --non-interactive flag skips prompts, --skip-cloud bypasses the Strapi Cloud login, and --js scaffolds JavaScript files instead of TypeScript. The --quickstart flag is deprecated in Strapi 5 documentation, which recommends using --non-interactive instead.

Start the development server:

cd my-esignature-backend
npm run develop

Your browser opens http://localhost:1337/admin. Fill in the registration form to create your local admin account. Keep this terminal running.

Step 2: Define the Document Signing Content-Type

Strapi 5 defines Content-Type models through schema.json files in the content-type structure, rather than by automatically aggregating all .js and .json files in each subdirectory. Create the following directory structure and file:

mkdir -p src/api/document-signing/content-types/document-signing
// src/api/document-signing/content-types/document-signing/schema.json
{
  "kind": "collectionType",
  "collectionName": "document_signings",
  "info": {
    "displayName": "Document Signing",
    "singularName": "document-signing",
    "pluralName": "document-signings",
    "description": "Tracks documents sent for e-signature"
  },
  "options": {
    "draftAndPublish": false
  },
  "attributes": {
    "documentTitle": {
      "type": "string",
      "required": true
    },
    "signerName": {
      "type": "string",
      "required": true
    },
    "signerEmail": {
      "type": "email",
      "required": true
    },
    "signatureImage": {
      "type": "media",
      "multiple": false,
      "required": false,
      "allowedTypes": ["images"]
    },
    "signatureStatus": {
      "type": "enumeration",
      "enum": ["pending", "signed", "rejected", "expired"],
      "default": "pending",
      "required": true
    },
    "signedAt": {
      "type": "datetime",
      "required": false
    }
  }
}

A few details worth noting:

  • draftAndPublish: false turns off the Strapi Draft and Publish system. The signatureStatus enumeration handles our workflow state instead.
  • signatureImage restricts uploads to image files with allowedTypes: ["images"]. The multiple: false setting means one signature image per entry.
  • The field is named signatureStatus, not status. In Strapi 5, status is a reserved attribute name. Other reserved names include meta, locale, and anything prefixed with strapi, _strapi, or __strapi. document is reserved in Strapi 5 and cannot be used as an attribute name. Using a reserved name is blocked by Strapi's validation, typically in the Content-Type Builder, and can cause startup or migration issues that require renaming the field or Content-Type.

Step 3: Create the Core Route, Controller, and Service

Strapi auto-generates REST endpoints for any Content-Type, but you need the core files in place. Create these three files:

mkdir -p src/api/document-signing/routes
mkdir -p src/api/document-signing/controllers
mkdir -p src/api/document-signing/services
// src/api/document-signing/routes/document-signing.js
const { createCoreRouter } = require('@strapi/strapi').factories;

module.exports = createCoreRouter(
  'api::document-signing.document-signing',
  {
    config: {
      find: {
        auth: false,
      },
      findOne: {
        auth: false,
      },
    },
  }
);

Setting auth: false on find and findOne makes those two endpoints publicly accessible without a token. The create, update, and delete routes still require authentication.

// src/api/document-signing/controllers/document-signing.js
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController(
  'api::document-signing.document-signing',
  ({ strapi }) => ({
    async find(ctx) {
      const { data, meta } = await super.find(ctx);
      return { data, meta };
    },

    async findOne(ctx) {
      const response = await super.findOne(ctx);
      return response;
    },

    async create(ctx) {
      const response = await super.create(ctx);
      return response;
    },

    async update(ctx) {
      const response = await super.update(ctx);
      return response;
    },

    async delete(ctx) {
      const response = await super.delete(ctx);
      return response;
    },
  })
);

When you extend a core controller with createCoreController, sanitization is inherited automatically. You don't need to call strapi.contentAPI.sanitize.output() manually.

// src/api/document-signing/services/document-signing.js
const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService(
  'api::document-signing.document-signing',
  ({ strapi }) => ({
    async find(params) {
      const { results, pagination } = await super.find(params);
      return { results, pagination };
    },

    async findOne(documentId, params) {
      const result = await super.findOne(documentId, params);
      return result;
    },

    async create(params) {
      const result = await super.create(params);
      return result;
    },

    async update(documentId, params) {
      const result = await super.update(documentId, params);
      return result;
    },

    async delete(documentId, params) {
      const result = await super.delete(documentId, params);
      return result;
    },
  })
);

Step 4: Add a Custom Sign Route

Beyond standard CRUD (Create, Read, Update, Delete), the platform needs a dedicated /sign endpoint that processes a signature for a specific document. Custom route files should be prefixed (e.g., 01-) to control load order relative to core routes.

// src/api/document-signing/routes/01-custom-sign.js
module.exports = {
  routes: [
    {
      method: 'POST',
      path: '/document-signings/:documentId/sign',
      handler: 'api::document-signing.document-signing.sign',
      config: {
        policies: [],
        middlewares: [],
      },
    },
  ],
};

Add the sign action to the controller:

// src/api/document-signing/controllers/document-signing.js (updated)
const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController(
  'api::document-signing.document-signing',
  ({ strapi }) => ({
    async find(ctx) {
      const { data, meta } = await super.find(ctx);
      return { data, meta };
    },

    async findOne(ctx) {
      const response = await super.findOne(ctx);
      return response;
    },

    async create(ctx) {
      const response = await super.create(ctx);
      return response;
    },

    async update(ctx) {
      const response = await super.update(ctx);
      return response;
    },

    async delete(ctx) {
      const response = await super.delete(ctx);
      return response;
    },

    async sign(ctx) {
      const { documentId } = ctx.params;
      const { signatureImageId } = ctx.request.body;

      if (!signatureImageId) {
        return ctx.badRequest('signatureImageId is required');
      }

      const result = await strapi
        .service('api::document-signing.document-signing')
        .processSignature(documentId, signatureImageId);

      ctx.send({ data: result });
    },
  })
);

Add the corresponding processSignature method to the service:

// src/api/document-signing/services/document-signing.js (updated)
const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService(
  'api::document-signing.document-signing',
  ({ strapi }) => ({
    async find(params) {
      const { results, pagination } = await super.find(params);
      return { results, pagination };
    },

    async findOne(documentId, params) {
      const result = await super.findOne(documentId, params);
      return result;
    },

    async create(params) {
      const result = await super.create(params);
      return result;
    },

    async update(documentId, params) {
      const result = await super.update(documentId, params);
      return result;
    },

    async delete(documentId, params) {
      const result = await super.delete(documentId, params);
      return result;
    },

    async processSignature(documentId, signatureImageId) {
      return await strapi
        .documents('api::document-signing.document-signing')
        .update({
          documentId,
          data: {
            signatureStatus: 'signed',
            signedAt: new Date().toISOString(),
            signatureImage: signatureImageId,
          },
        });
    },
  })
);

The processSignature method calls the Document Service API directly via strapi.documents(). This is the v5 replacement for the deprecated Entity Service. The documentId parameter is a 24-character alphanumeric string, not the numeric id from Strapi v4.

Step 5: Configure Permissions and Create an API Token

Restart the development server to pick up the new Content-Type:

npm run develop

Set public permissions for read-only access:

  1. Go to Settings > Users and Permissions > Roles > Public in the Admin Panel
  2. Under Document-signing, check find and findOne
  3. Click Save

Create an API token for write operations (creating entries, uploading files, and signing documents):

  1. Navigate to Settings > Global settings > API Tokens
  2. Click Create new API Token
  3. Set Name to esignature-frontend
  4. Set Token duration to Unlimited (for development)
  5. Set Token type to Full access
  6. Click Save and copy the token immediately. It is displayed only once unless you configure an encryption key.

Save the token. You need it for the frontend .env file in the next section. For broader guidance on API security, that checklist covers additional hardening steps.

After this step, Strapi automatically provides these REST endpoints:

MethodEndpointAuth Required
GET/api/document-signingsNo (public)
GET/api/document-signings/:documentIdNo (public)
POST/api/document-signingsYes (API token)
PUT/api/document-signings/:documentIdYes (API token)
DELETE/api/document-signings/:documentIdYes (API token)
POST/api/document-signings/:documentId/signYes (API token)

Note that Strapi 5 endpoints use documentId (a string) in the URL path, not the integer id from v4.

Building the React Frontend

Step 1: Scaffold the React and Vite Project

Open a new terminal (keep Strapi running) and scaffold the frontend:

npm create vite@latest my-esignature-app -- --template react
cd my-esignature-app
npm install

Install the signature capture libraries:

npm install signature_pad@5.1.3 react-signature-canvas@1.1.0-alpha.2

The signature_pad library has zero runtime dependencies and provides the core canvas drawing logic. The react-signature-canvas package wraps it in a React component with extra convenience methods like getTrimmedCanvas(). For a broader look at building CRUD apps with React, that guide covers the fundamentals.

Step 2: Configure Environment Variables

Create a .env file in the frontend project root:

# .env
VITE_STRAPI_URL=http://localhost:1337
VITE_STRAPI_TOKEN=your-api-token-from-step-5

Vite only exposes variables prefixed with VITE_ to the browser bundle. Variables without this prefix are intentionally stripped at build time.

Step 3: Create the API Helper Module

This module centralizes all Strapi REST API calls. Strapi 5 uses a flat response format where fields sit directly on the data object, not nested under data.attributes as in v4. For details on how populate and filtering work with the REST API, that guide covers the patterns.

// src/api/strapi.js
const BASE_URL = import.meta.env.VITE_STRAPI_URL || 'http://localhost:1337';
const API_TOKEN = import.meta.env.VITE_STRAPI_TOKEN;

const jsonHeaders = {
  Authorization: `Bearer ${API_TOKEN}`,
  'Content-Type': 'application/json',
};

export async function fetchDocuments() {
  const response = await fetch(
    `${BASE_URL}/api/document-signings?populate=signatureImage`,
    {
      method: 'GET',
      headers: jsonHeaders,
    }
  );

  if (!response.ok) {
    throw new Error(`Failed to fetch documents: ${response.status}`);
  }

  const json = await response.json();
  return json.data;
}

export async function createDocument(documentTitle, signerName, signerEmail) {
  const response = await fetch(`${BASE_URL}/api/document-signings`, {
    method: 'POST',
    headers: jsonHeaders,
    body: JSON.stringify({
      data: {
        documentTitle,
        signerName,
        signerEmail,
        signatureStatus: 'pending',
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`Failed to create document: ${response.status}`);
  }

  const json = await response.json();
  return json.data;
}

export async function uploadSignatureImage(blob) {
  const formData = new FormData();
  formData.append('files', blob, 'signature.png');
  formData.append(
    'fileInfo',
    JSON.stringify({
      name: 'signature',
      alternativeText: 'User signature',
    })
  );

  const response = await fetch(`${BASE_URL}/api/upload`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${API_TOKEN}`,
    },
    body: formData,
  });

  if (!response.ok) {
    throw new Error(`Failed to upload signature: ${response.status}`);
  }

  const files = await response.json();
  return files[0];
}

export async function signDocument(documentId, signatureImageId) {
  const response = await fetch(
    `${BASE_URL}/api/document-signings/${documentId}/sign`,
    {
      method: 'POST',
      headers: jsonHeaders,
      body: JSON.stringify({
        signatureImageId,
      }),
    }
  );

  if (!response.ok) {
    throw new Error(`Failed to sign document: ${response.status}`);
  }

  const json = await response.json();
  return json.data;
}

Two things to notice in uploadSignatureImage:

  1. No Content-Type header is set. When sending FormData, the browser automatically sets Content-Type: multipart/form-data with the correct boundary parameter. Setting it manually omits the boundary and causes parse failures on the server.
  2. This is step one of the two-step upload pattern. Strapi 5 no longer supports uploading files during entry creation. You upload the file first to /api/upload, get back a numeric file id, then reference that id when creating or updating the content entry.

Step 4: Build the Signature Pad Component

This component wraps react-signature-canvas and handles the canvas-to-Blob conversion. The canvas.toBlob() method is preferred over toDataURL() because it produces a binary Blob directly, avoiding the ~33% size overhead of base64 encoding.

// src/components/SignaturePad.jsx
import { useRef, useCallback } from 'react';
import SignatureCanvas from 'react-signature-canvas';

export default function SignaturePad({ onSignatureReady, disabled }) {
  const sigCanvasRef = useRef(null);

  const handleClear = useCallback(() => {
    if (sigCanvasRef.current) {
      sigCanvasRef.current.clear();
    }
  }, []);

  const handleConfirm = useCallback(() => {
    if (!sigCanvasRef.current) return;

    if (sigCanvasRef.current.isEmpty()) {
      alert('Please draw your signature before confirming.');
      return;
    }

    const trimmedCanvas = sigCanvasRef.current.getTrimmedCanvas();

    trimmedCanvas.toBlob(
      (blob) => {
        if (!blob) {
          alert('Failed to capture signature. Please try again.');
          return;
        }
        onSignatureReady(blob);
      },
      'image/png'
    );
  }, [onSignatureReady]);

  return (
    <div>
      <SignatureCanvas
        ref={sigCanvasRef}
        penColor="black"
        canvasProps={{
          width: 500,
          height: 200,
          className: 'signature-canvas',
          style: {
            border: '1px solid #ccc',
            borderRadius: '4px',
            cursor: disabled ? 'not-allowed' : 'crosshair',
          },
        }}
        backgroundColor="rgb(255,255,255)"
        clearOnResize={false}
      />
      <div style={{ marginTop: '8px', display: 'flex', gap: '8px' }}>
        <button type="button" onClick={handleClear} disabled={disabled}>
          Clear
        </button>
        <button type="button" onClick={handleConfirm} disabled={disabled}>
          Confirm Signature
        </button>
      </div>
    </div>
  );
}

The backgroundColor is set to "rgb(255,255,255)" (opaque white) because the default transparent background can turn black when exported as JPEG. PNG supports transparency, but an opaque background looks better in most document contexts. The clearOnResize={false} prop prevents the canvas from clearing when the browser window resizes.

getTrimmedCanvas() is a method specific to react-signature-canvas that crops whitespace around the signature before export.

Step 5: Build the Document List Component

This component fetches and displays all document signing entries from Strapi. The Strapi 5 REST API does not populate relations, media fields, or components by default, so the fetchDocuments call includes ?populate=signatureImage to load signature images.

// src/components/DocumentList.jsx
import { useState, useEffect } from 'react';
import { fetchDocuments } from '../api/strapi.js';

const STRAPI_URL = import.meta.env.VITE_STRAPI_URL || 'http://localhost:1337';

export default function DocumentList({ refreshKey }) {
  const [documents, setDocuments] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);
    setError(null);

    fetchDocuments()
      .then((data) => {
        setDocuments(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, [refreshKey]);

  if (loading) return <p>Loading documents...</p>;
  if (error) return <p style={{ color: 'red' }}>Error: {error}</p>;
  if (documents.length === 0) return <p>No documents yet.</p>;

  return (
    <table style={{ width: '100%', borderCollapse: 'collapse' }}>
      <thead>
        <tr>
          <th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Title</th>
          <th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Signer</th>
          <th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Email</th>
          <th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Status</th>
          <th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Signed At</th>
          <th style={{ textAlign: 'left', padding: '8px', borderBottom: '2px solid #ddd' }}>Signature</th>
        </tr>
      </thead>
      <tbody>
        {documents.map((doc) => (
          <tr key={doc.documentId}>
            <td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>{doc.documentTitle}</td>
            <td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>{doc.signerName}</td>
            <td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>{doc.signerEmail}</td>
            <td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
              <span
                style={{
                  padding: '2px 8px',
                  borderRadius: '12px',
                  fontSize: '0.85em',
                  backgroundColor: doc.signatureStatus === 'signed' ? '#d4edda' : '#fff3cd',
                  color: doc.signatureStatus === 'signed' ? '#155724' : '#856404',
                }}
              >
                {doc.signatureStatus}
              </span>
            </td>
            <td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
              {doc.signedAt ? new Date(doc.signedAt).toLocaleString() : '—'}
            </td>
            <td style={{ padding: '8px', borderBottom: '1px solid #eee' }}>
              {doc.signatureImage ? (
                <img
                  src={`${STRAPI_URL}${doc.signatureImage.url}`}
                  alt={`Signature for ${doc.documentTitle}`}
                  style={{ maxWidth: '120px', maxHeight: '48px' }}
                />
              ) : (
                '—'
              )}
            </td>
          </tr>
        ))}
      </tbody>
    </table>
  );
}

Each document uses doc.documentId as the React key. This is the 24-character string identifier that Strapi 5 returns for every entry. The numeric id field still exists in responses, but documentId is the stable identifier for all API operations.

Step 6: Build the Signing Form Component

This component ties together document creation, signature capture, file upload, and the custom /sign endpoint in a single workflow:

// src/components/SigningForm.jsx
import { useState, useCallback } from 'react';
import SignaturePad from './SignaturePad.jsx';
import {
  createDocument,
  uploadSignatureImage,
  signDocument,
} from '../api/strapi.js';

export default function SigningForm({ onDocumentSigned }) {
  const [formData, setFormData] = useState({
    documentTitle: '',
    signerName: '',
    signerEmail: '',
  });
  const [signatureBlob, setSignatureBlob] = useState(null);
  const [submitting, setSubmitting] = useState(false);
  const [statusMessage, setStatusMessage] = useState('');

  const handleInputChange = useCallback((e) => {
    const { name, value } = e.target;
    setFormData((prev) => ({ ...prev, [name]: value }));
  }, []);

  const handleSignatureReady = useCallback((blob) => {
    setSignatureBlob(blob);
    setStatusMessage('Signature captured. Ready to submit.');
  }, []);

  const handleSubmit = async (e) => {
    e.preventDefault();

    if (!signatureBlob) {
      setStatusMessage('Please draw and confirm your signature first.');
      return;
    }

    setSubmitting(true);
    setStatusMessage('Creating document...');

    try {
      const newDoc = await createDocument(
        formData.documentTitle,
        formData.signerName,
        formData.signerEmail
      );

      setStatusMessage('Uploading signature image...');
      const uploadedFile = await uploadSignatureImage(signatureBlob);

      setStatusMessage('Applying signature...');
      await signDocument(newDoc.documentId, uploadedFile.id);

      setStatusMessage('Document signed successfully!');
      setFormData({ documentTitle: '', signerName: '', signerEmail: '' });
      setSignatureBlob(null);
      onDocumentSigned();
    } catch (err) {
      setStatusMessage(`Error: ${err.message}`);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <div style={{ marginBottom: '12px' }}>
        <label htmlFor="documentTitle">Document Title</label>
        <br />
        <input
          id="documentTitle"
          name="documentTitle"
          type="text"
          value={formData.documentTitle}
          onChange={handleInputChange}
          required
          disabled={submitting}
          style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
        />
      </div>

      <div style={{ marginBottom: '12px' }}>
        <label htmlFor="signerName">Your Name</label>
        <br />
        <input
          id="signerName"
          name="signerName"
          type="text"
          value={formData.signerName}
          onChange={handleInputChange}
          required
          disabled={submitting}
          style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
        />
      </div>

      <div style={{ marginBottom: '12px' }}>
        <label htmlFor="signerEmail">Your Email</label>
        <br />
        <input
          id="signerEmail"
          name="signerEmail"
          type="email"
          value={formData.signerEmail}
          onChange={handleInputChange}
          required
          disabled={submitting}
          style={{ width: '100%', padding: '8px', boxSizing: 'border-box' }}
        />
      </div>

      <div style={{ marginBottom: '12px' }}>
        <label>Signature</label>
        <SignaturePad onSignatureReady={handleSignatureReady} disabled={submitting} />
        {signatureBlob && (
          <p style={{ color: 'green', fontSize: '0.9em' }}>
            ✓ Signature captured ({Math.round(signatureBlob.size / 1024)} KB)
          </p>
        )}
      </div>

      <button
        type="submit"
        disabled={submitting || !signatureBlob}
        style={{
          padding: '10px 24px',
          backgroundColor: submitting || !signatureBlob ? '#ccc' : '#4945ff',
          color: '#fff',
          border: 'none',
          borderRadius: '4px',
          cursor: submitting || !signatureBlob ? 'not-allowed' : 'pointer',
        }}
      >
        {submitting ? 'Processing...' : 'Sign Document'}
      </button>

      {statusMessage && (
        <p style={{ marginTop: '12px', fontStyle: 'italic' }}>{statusMessage}</p>
      )}
    </form>
  );
}

The submit handler executes three sequential API calls:

  1. createDocument() sends a POST to /api/document-signings with signatureStatus: 'pending'
  2. uploadSignatureImage() sends the signature Blob to /api/upload and returns the uploaded file object with its numeric id
  3. signDocument() invokes the custom backend action that handles the document-signing flow, updating signatureStatus to 'signed', setting signedAt, and linking the signatureImage

The file's numeric id from the upload response identifies the uploaded file record; linking that media to a content entry uses the entry's refId (along with ref, source, and field). This is distinct from the documentId string used for content entry operations.

Step 7: Assemble the App Component

// src/App.jsx
import { useState, useCallback } from 'react';
import SigningForm from './components/SigningForm.jsx';
import DocumentList from './components/DocumentList.jsx';

export default function App() {
  const [refreshKey, setRefreshKey] = useState(0);

  const handleDocumentSigned = useCallback(() => {
    setRefreshKey((prev) => prev + 1);
  }, []);

  return (
    <div style={{ maxWidth: '720px', margin: '0 auto', padding: '24px', fontFamily: 'system-ui, sans-serif' }}>
      <h1>e-Signature Platform</h1>

      <section style={{ marginBottom: '48px' }}>
        <h2>Sign a Document</h2>
        <SigningForm onDocumentSigned={handleDocumentSigned} />
      </section>

      <section>
        <h2>Signed Documents</h2>
        <DocumentList refreshKey={refreshKey} />
      </section>
    </div>
  );
}

Replace the default src/main.jsx to clean up the Vite boilerplate:

// src/main.jsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

Remove the default src/App.css and src/index.css imports if present, or replace them with minimal styles. The inline styles in the components above keep the tutorial self-contained.

Putting It All Together

Start Both Servers

Terminal 1 (Strapi backend):

cd my-esignature-backend
npm run develop

Strapi starts on http://localhost:1337. The Admin Panel is at http://localhost:1337/admin.

Terminal 2 (React frontend):

cd my-esignature-app
npm run dev

Vite starts on http://localhost:5173 (default port).

Walk Through the Signing Flow

  1. Open http://localhost:5173 in your browser
  2. Fill in "Document Title" (e.g., Service Agreement), "Your Name", and "Your Email"
  3. Draw your signature on the canvas pad
  4. Click Confirm Signature. A green confirmation message shows the captured Blob size.
  5. Click Sign Document. The status messages cycle through: "Creating document..." → "Uploading signature image..." → "Applying signature..." → "Document signed successfully!"
  6. The documents table below refreshes and shows the new entry with status signed, the timestamp, and a thumbnail of the signature image

Verify via Curl

You can also confirm the API is working directly:

curl -s http://localhost:1337/api/document-signings?populate=signatureImage | python3 -m json.tool

The response follows Strapi 5's flat format:

{
  "data": [
    {
      "id": 1,
      "documentId": "cvsz61qg33rtyv1qljb1nrtg",
      "documentTitle": "Service Agreement",
      "signerName": "Jane Smith",
      "signerEmail": "jane@example.com",
      "signatureStatus": "signed",
      "signedAt": "2026-06-01T14:30:00.000Z",
      "signatureImage": {
        "id": 1,
        "documentId": "cf07g1dbusqr8mzmlbqvlegx",
        "name": "signature.png",
        "url": "/uploads/signature_abc123.png"
      }
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 1
    }
  }
}

Fields sit directly on the data object. No attributes wrapper, no nested access patterns. If you're migrating from v4 code, the response format guide covers the differences.

Next Steps

  • Deploy to production. Host the Strapi backend on Strapi Cloud or a self-hosted provider, and deploy the React frontend to Vercel. Switch from SQLite to PostgreSQL for production database workloads. The Strapi deployment guide covers hosting options.
  • Add filtering and search. Use Strapi 5's filter operators ($eq, $containsi, $between) to let users search documents by status, signer email, or date range. The qs library simplifies complex filter queries.
  • Implement user authentication. Replace the static API token with the Users and Permissions plugin so signers log in via /api/auth/local and receive a JWT. Control access in Strapi through roles, permissions, and policies. For more on how API authorization works, that guide covers the mechanics.
  • Add document hashing for integrity verification. Compute a SHA-256 hash of the document content before and after signing, and store both hashes on the entry. This aligns with the EU's eIDAS regulation, which requires that post-signature changes be detectable for Advanced Electronic Signatures.
  • Build an audit trail. Add a separate audit-log Collection Type that records every state transition (created, signed, and rejected) with timestamps and IP addresses. Link it to the document-signing type via a relation field. For guidance on data validation, that guide covers server-side input checking.

Paul BratslavskyDeveloper Advocate
How to Build a Translation Memory and Localization Workflow Tool with Strapi 5
Ecosystem·16 min read

How to Build a Translation Memory and Localization Workflow Tool with Strapi 5

Learn to build a translation memory and localization workflow tool with Strapi 5 using custom content-types, fuzzy matching, lifecycle hooks, and REST API.

·July 29, 2026
How to Build Your Personal Library and Book Catalog System Using Strapi 5
Ecosystem·15 min read

How to Build Your Personal Library and Book Catalog System Using Strapi 5

Build a personal library catalog with Strapi 5. Define collection types, seed data via Document Service API, and display books in a vanilla JS frontend.

July 29, 2026
Express.js Explained: A Beginner's Guide to Node.js Web Framework Basics
Ecosystem·16 min read

Express.js Explained: A Beginner's Guide to Node.js Web Framework Basics

Learn how Express.js works, what it gives you over raw Node.js, and where it fits in 2026. Covers routing, middleware, async error handling, and Express 5.

July 29, 2026