July 4, 2026 · Tutorial

Add E-Signatures to Your Next.js App Router Project in 15 Minutes (2026)

Server Actions for document sends. Route Handlers for webhook events. Real-time signing status in your React components. Here's the complete integration — no SDK, no OAuth, no envelope templates.

Michael Beckett
Michael Beckett

Founder, Signbee

TL;DR

Next.js App Router has everything you need for a production e-signature integration. Server Actions keep your API key on the server. Route Handlers receive webhook events. React Server Components render signing status without client-side JavaScript. This tutorial walks through all four pieces — from first API call to real-time status updates — using Signbee's single-endpoint API. No SDK, no npm dependencies, 15 minutes start to finish.

Prerequisites

You need two things to follow this tutorial:

Next.js version14 or 15 (App Router)
React version18+ (Server Components)
Signbee API keyFree at signb.ee/docs
Additional dependenciesNone (zero npm packages)

Next.js App Router uses React Server Components by default, which means your server-side code runs in a Node.js environment with native fetch(). No SDK needed. No npm install. Your package.json stays clean. If you're coming from a Pages Router project, the key difference is that Server Actions replace API routes for mutations, giving you type-safe, progressively-enhanced form submissions out of the box.

Grab your API key from the Signbee dashboard — the free tier includes 5 documents per month, no credit card required. Add it to your .env.local:

.env.local
SIGNBEE_API_KEY=sb_live_your_api_key_here
SIGNBEE_WEBHOOK_SECRET=whsec_your_webhook_secret_here

Step 1: Create a Server Action to send documents

Server Actions are the Next.js App Router way to handle server-side mutations. They run exclusively on the server, which means your API key in process.env is never exposed to the client. Create a new file for your signing actions:

app/actions/signing.ts — Server Action
"use server";

interface SendDocumentInput {
  markdown: string;
  recipientName: string;
  recipientEmail: string;
}

interface SendDocumentResult {
  success: boolean;
  documentId?: string;
  signingUrl?: string;
  error?: string;
}

export async function sendForSignature(
  input: SendDocumentInput
): Promise<SendDocumentResult> {
  const response = await fetch("https://signb.ee/api/v1/send", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${process.env.SIGNBEE_API_KEY}`,
    },
    body: JSON.stringify({
      markdown: input.markdown,
      recipient_name: input.recipientName,
      recipient_email: input.recipientEmail,
    }),
  });

  if (!response.ok) {
    const errorText = await response.text();

    if (response.status === 429) {
      const retryAfter = response.headers.get("Retry-After") || "5";
      return {
        success: false,
        error: `Rate limited. Please retry after ${retryAfter} seconds.`,
      };
    }

    if (response.status === 401) {
      return {
        success: false,
        error: "Invalid API key. Check your SIGNBEE_API_KEY env variable.",
      };
    }

    return {
      success: false,
      error: `API error ${response.status}: ${errorText}`,
    };
  }

  const data = await response.json();
  return {
    success: true,
    documentId: data.document_id,
    signingUrl: data.signing_url,
  };
}

A few things to notice here. The "use server" directive at the top tells Next.js this entire module runs on the server — every exported function becomes a Server Action. The return type is a plain object (not a Response), because Server Actions serialize their return values over the network to the calling client component. And the error handling is granular: 429 rate limits, 401 auth errors, and generic API failures each get distinct messages. For a deeper dive into rate limit patterns, see our rate limits guide.

Step 2: Build a React form component

Now create a client component that uses the Server Action. This component collects the recipient's details, triggers the send, and shows real-time feedback:

components/SendContractForm.tsx — React client component
"use client";

import { useState, useTransition } from "react";
import { sendForSignature } from "@/app/actions/signing";

export function SendContractForm() {
  const [isPending, startTransition] = useTransition();
  const [result, setResult] = useState<{
    success?: boolean;
    documentId?: string;
    error?: string;
  } | null>(null);

  function handleSubmit(formData: FormData) {
    startTransition(async () => {
      const res = await sendForSignature({
        markdown: formData.get("markdown") as string,
        recipientName: formData.get("recipientName") as string,
        recipientEmail: formData.get("recipientEmail") as string,
      });
      setResult(res);
    });
  }

  return (
    <form action={handleSubmit} className="space-y-4 max-w-md">
      <div>
        <label htmlFor="recipientName" className="block text-sm mb-1">
          Recipient name
        </label>
        <input
          id="recipientName"
          name="recipientName"
          type="text"
          required
          className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700
                     rounded-md text-white"
          placeholder="Jane Smith"
        />
      </div>

      <div>
        <label htmlFor="recipientEmail" className="block text-sm mb-1">
          Recipient email
        </label>
        <input
          id="recipientEmail"
          name="recipientEmail"
          type="email"
          required
          className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700
                     rounded-md text-white"
          placeholder="jane@example.com"
        />
      </div>

      <div>
        <label htmlFor="markdown" className="block text-sm mb-1">
          Contract content (markdown)
        </label>
        <textarea
          id="markdown"
          name="markdown"
          rows={6}
          required
          className="w-full px-3 py-2 bg-zinc-900 border border-zinc-700
                     rounded-md text-white font-mono text-sm"
          defaultValue={`# Service Agreement

**Client:** {{recipient_name}}
**Date:** ${new Date().toLocaleDateString()}

## Terms
1. Services begin on the effective date
2. Payment due within 30 days of invoice
3. Either party may terminate with 30 days notice`}
        />
      </div>

      <button
        type="submit"
        disabled={isPending}
        className="px-4 py-2 bg-amber-400 text-black font-medium rounded-md
                   hover:bg-amber-300 disabled:opacity-50 transition-colors"
      >
        {isPending ? "Sending..." : "Send for Signature"}
      </button>

      {result?.success && (
        <div className="bg-emerald-400/10 border border-emerald-400/20
                        rounded-md p-3 text-sm text-emerald-400">
          ✓ Document sent. ID: {result.documentId}
        </div>
      )}

      {result?.error && (
        <div className="bg-red-400/10 border border-red-400/20
                        rounded-md p-3 text-sm text-red-400">
          {result.error}
        </div>
      )}
    </form>
  );
}

This component uses useTransition instead of manual loading state — the React 18+ way to handle async mutations. The form uses the native action attribute, which means it works even before JavaScript hydrates (progressive enhancement). The sendForSignature Server Action is imported directly and called with typed arguments. No fetch("/api/...") plumbing, no manual serialization.

If you've built signing components with raw fetch() before, you'll appreciate how much boilerplate this removes. For a comparison of the traditional React component approach, see our React signing component tutorial.

Step 3: Create a webhook Route Handler

Sending documents is half the integration. You also need to know when they're signed, viewed, or declined. Signbee fires webhook events to a URL you configure. In Next.js App Router, you handle these with a Route Handler:

app/api/webhooks/signbee/route.ts — Route Handler
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";

// TypeScript types for Signbee webhook payloads
interface SignbeeWebhookPayload {
  event:
    | "document.sent"
    | "document.viewed"
    | "document.signed"
    | "document.declined";
  timestamp: string;
  data: {
    document_id: string;
    signer_name: string;
    signer_email: string;
    signed_pdf_url?: string;
    ip_address: string;
    user_agent: string;
  };
}

function verifyWebhookSignature(
  body: string,
  signature: string | null
): boolean {
  if (!signature) return false;
  const secret = process.env.SIGNBEE_WEBHOOK_SECRET;
  if (!secret) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

export async function POST(req: NextRequest) {
  // Read raw body before parsing for signature verification
  const rawBody = await req.text();
  const signature = req.headers.get("x-signbee-signature");

  if (!verifyWebhookSignature(rawBody, signature)) {
    return NextResponse.json(
      { error: "Invalid signature" },
      { status: 401 }
    );
  }

  const payload: SignbeeWebhookPayload = JSON.parse(rawBody);

  switch (payload.event) {
    case "document.sent":
      console.log(
        `📤 Document ${payload.data.document_id} sent to ${payload.data.signer_email}`
      );
      // await db.documents.update({
      //   where: { id: payload.data.document_id },
      //   data: { status: "sent", sentAt: new Date(payload.timestamp) },
      // });
      break;

    case "document.viewed":
      console.log(
        `👁️ ${payload.data.signer_name} viewed ${payload.data.document_id}`
      );
      // await db.documents.update({
      //   where: { id: payload.data.document_id },
      //   data: { status: "viewed", viewedAt: new Date(payload.timestamp) },
      // });
      break;

    case "document.signed":
      console.log(
        `✅ ${payload.data.signer_name} signed ${payload.data.document_id}`
      );
      // await db.documents.update({
      //   where: { id: payload.data.document_id },
      //   data: {
      //     status: "signed",
      //     signedAt: new Date(payload.timestamp),
      //     pdfUrl: payload.data.signed_pdf_url,
      //   },
      // });
      break;

    case "document.declined":
      console.log(
        `❌ ${payload.data.signer_name} declined ${payload.data.document_id}`
      );
      // await db.documents.update({
      //   where: { id: payload.data.document_id },
      //   data: { status: "declined", declinedAt: new Date(payload.timestamp) },
      // });
      break;
  }

  return NextResponse.json({ received: true });
}

The critical detail here is signature verification. The handler reads the raw body as text first, verifies the HMAC-SHA256 signature against the x-signbee-signature header, and only then parses the JSON. If you parse JSON first and then re-stringify it, whitespace differences will break the signature. This is the most common webhook verification bug I see in production integrations.

The crypto.timingSafeEqual comparison prevents timing attacks — a subtle but important security detail. Never use === to compare signatures. For a complete webhook implementation walkthrough, including retry handling and idempotency patterns, see our webhook events deep dive.

Uncomment the database calls and replace with your ORM of choice — Prisma, Drizzle, or raw SQL. Signbee retries failed webhook deliveries with exponential backoff for up to 72 hours, so transient database errors won't cause lost events.

Step 4: Display signing status in real-time

Once your webhook handler updates document status in your database, you need to surface that status in the UI. Here's a polling hook that checks status every 5 seconds after a document is sent:

hooks/useSigningStatus.ts — status polling hook
"use client";

import { useEffect, useState } from "react";

type DocumentStatus =
  | "pending"
  | "sent"
  | "viewed"
  | "signed"
  | "declined";

export function useSigningStatus(documentId: string | null) {
  const [status, setStatus] = useState<DocumentStatus>("pending");

  useEffect(() => {
    if (!documentId) return;

    const interval = setInterval(async () => {
      try {
        const res = await fetch(
          `/api/documents/${documentId}/status`
        );
        if (!res.ok) return;

        const data = await res.json();
        setStatus(data.status);

        // Stop polling on terminal states
        if (data.status === "signed" || data.status === "declined") {
          clearInterval(interval);
        }
      } catch {
        // Silently handle network errors — next poll will retry
      }
    }, 5000);

    return () => clearInterval(interval);
  }, [documentId]);

  return status;
}

And the corresponding status API route that your hook polls:

app/api/documents/[id]/status/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(
  req: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params;

  // Replace with your database query
  // const doc = await db.documents.findUnique({ where: { id } });
  // return NextResponse.json({ status: doc?.status ?? "pending" });

  // Placeholder response
  return NextResponse.json({ status: "pending", documentId: id });
}

Now build a status badge component that wires everything together:

components/SigningStatusBadge.tsx
"use client";

import { useSigningStatus } from "@/hooks/useSigningStatus";

const statusConfig: Record<string, { label: string; className: string }> = {
  pending:  { label: "Pending",  className: "bg-zinc-400/10 text-zinc-400" },
  sent:     { label: "Sent",     className: "bg-blue-400/10 text-blue-400" },
  viewed:   { label: "Viewed",   className: "bg-amber-400/10 text-amber-400" },
  signed:   { label: "Signed",   className: "bg-emerald-400/10 text-emerald-400" },
  declined: { label: "Declined", className: "bg-red-400/10 text-red-400" },
};

export function SigningStatusBadge({
  documentId,
}: {
  documentId: string | null;
}) {
  const status = useSigningStatus(documentId);
  const config = statusConfig[status] ?? statusConfig.pending;

  return (
    <span className={`inline-flex items-center px-2 py-1 rounded-full
                      text-xs font-medium ${config.className}`}>
      {config.label}
    </span>
  );
}

For production apps with many concurrent documents, consider upgrading from polling to Server-Sent Events. Your webhook Route Handler pushes events to an SSE endpoint, and your React component subscribes with EventSource. This eliminates the 5-second polling delay and reduces unnecessary network requests. The polling approach above is fine for apps handling fewer than 50 concurrent signing sessions.

Full TypeScript types

Here are the complete TypeScript definitions for the entire integration. Drop these into a shared types file:

types/signbee.ts — complete type definitions
// API request and response types
export interface SignbeeSendRequest {
  markdown: string;
  recipient_name: string;
  recipient_email: string;
  webhook_url?: string;
  redirect_url?: string;
}

export interface SignbeeSendResponse {
  document_id: string;
  signing_url: string;
  status: "sent" | "viewed" | "signed" | "declined";
}

// Webhook event types
export type SignbeeEventType =
  | "document.sent"
  | "document.viewed"
  | "document.signed"
  | "document.declined";

export interface SignbeeWebhookEvent {
  event: SignbeeEventType;
  timestamp: string;
  data: {
    document_id: string;
    signer_name: string;
    signer_email: string;
    signed_pdf_url?: string;
    ip_address: string;
    user_agent: string;
  };
}

// Server Action result type
export interface SigningActionResult {
  success: boolean;
  documentId?: string;
  signingUrl?: string;
  error?: string;
}

The Signbee API surface is small enough that a single types file covers the entire integration. No generated types from an OpenAPI spec needed. For the complete API reference and all available fields, see the documentation.

Error handling patterns

Production integrations hit edge cases that tutorials usually ignore. Here are the error scenarios you should handle and the recommended patterns for each:

Network timeoutSet AbortController with 10s timeout
401 UnauthorizedCheck SIGNBEE_API_KEY in env
422 Validation ErrorSurface field-level errors in form
429 Rate LimitedRespect Retry-After header, queue retry
500 Server ErrorRetry once with backoff, then alert
Webhook replayStore document_id + event as idempotency key
Duplicate webhooksMake handlers idempotent (upsert, not insert)

The most common production issue is duplicate webhook deliveries. Signbee retries webhooks if your server returns a non-2xx status, which means your handler might process the same document.signed event twice. Make your database writes idempotent — use upsert instead of insert, and check for existing records before triggering side effects like sending confirmation emails.

Here's a hardened version of the Server Action with timeout handling:

Server Action — with AbortController timeout
"use server";

export async function sendForSignatureWithTimeout(
  input: { markdown: string; recipientName: string; recipientEmail: string }
) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10000);

  try {
    const response = await fetch("https://signb.ee/api/v1/send", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${process.env.SIGNBEE_API_KEY}`,
      },
      body: JSON.stringify({
        markdown: input.markdown,
        recipient_name: input.recipientName,
        recipient_email: input.recipientEmail,
      }),
      signal: controller.signal,
    });

    clearTimeout(timeout);

    if (!response.ok) {
      return { success: false, error: await response.text() };
    }

    const data = await response.json();
    return { success: true, documentId: data.document_id };
  } catch (err) {
    clearTimeout(timeout);
    if (err instanceof DOMException && err.name === "AbortError") {
      return { success: false, error: "Request timed out after 10s." };
    }
    return { success: false, error: "Network error. Please try again." };
  }
}

Deployment considerations

Deploying a Next.js App Router project with e-signature integration has a few gotchas that are easy to miss. Here's what you need to configure on Vercel (or any hosting provider):

ConfigurationWhereNotes
SIGNBEE_API_KEYEnvironment variablesProduction + Preview
SIGNBEE_WEBHOOK_SECRETEnvironment variablesProduction only
Webhook URLSignbee dashboardhttps://yourapp.com/api/webhooks/signbee
Function timeoutvercel.jsonDefault 10s is usually sufficient
Body size limitRoute config4.5 MB default covers most contracts

Important: Don't prefix your API key with NEXT_PUBLIC_. Server Actions and Route Handlers run on the server, so process.env.SIGNBEE_API_KEY works without the public prefix. Using NEXT_PUBLIC_ would expose your key in the client bundle — a security vulnerability. This is the most common deployment mistake I see in Next.js e-signature integrations.

For local development, use ngrok or localtunnel to expose your localhost webhook endpoint to the internet. Signbee needs a publicly accessible URL to deliver webhook events. In the Signbee dashboard, set your webhook URL to the ngrok tunnel URL during development, and update it to your production domain before deploying.

If you're deploying to Vercel's Edge Runtime, note that the crypto module used for webhook verification needs the Node.js runtime. Add the runtime directive to your Route Handler:

Route Handler — Node.js runtime directive
// Force Node.js runtime for crypto module
export const runtime = "nodejs";

Putting it all together

Here's the complete file structure for your Next.js App Router e-signature integration. Four files, zero additional npm dependencies:

Server Actionapp/actions/signing.ts
Form componentcomponents/SendContractForm.tsx
Webhook handlerapp/api/webhooks/signbee/route.ts
Status hookhooks/useSigningStatus.ts
Status badgecomponents/SigningStatusBadge.tsx
Type definitionstypes/signbee.ts

The entire integration follows the Next.js App Router conventions: Server Actions for outbound API calls, Route Handlers for inbound webhooks, client components for interactive UI, and shared types for end-to-end type safety. No patterns fighting the framework. For a broader overview of adding e-signatures to any web app, see our 10-minute web app integration guide.

Next steps

Once you have the basic flow working, explore these related patterns:

Frequently Asked Questions

How do I add e-signatures to a Next.js App Router project?

Create a Server Action that calls the Signbee API with your document content as markdown, the recipient's name, and their email. The Server Action runs on the server, keeping your API key secure. Build a React form component that invokes the action, and set up a Route Handler at /api/webhooks/signbee to receive status updates. The entire integration requires zero additional npm packages and takes about 15 minutes.

Can I use Server Actions instead of API routes for e-signatures?

Yes — Server Actions are the recommended approach for Next.js App Router. They run on the server by default, keep your API key out of client bundles, provide progressive enhancement (forms work without JavaScript), and integrate with React's useTransition for optimistic UI. Use Route Handlers only for inbound webhooks, since those are external HTTP calls that need a public URL endpoint.

How do I verify webhook signatures in Next.js?

Read the raw request body as text with req.text(), compute an HMAC-SHA256 of it using your webhook secret, and compare the result to the x-signbee-signature header with crypto.timingSafeEqual. Parse JSON only after verification — parsing first can alter whitespace and break the signature. This prevents replay attacks and ensures webhooks are genuinely from Signbee.

Next.js App Router + Signbee — 5 free docs/month, 15 minutes to production.

Last updated: July 10, 2026 · Michael Beckett is the founder of Signbee and B2bee Ltd.

Related resources