Skip to main content
developers/donations-sdk/integration-examples/nextjs
Developer8 min read

Next.js integration

Add the Donations Embed to a Next.js app — App Router or Pages Router. Includes the webhook route handler example.

Next.js integration

This guide adds the Donations Embed to a Next.js application. It covers both the App Router (Next 13.4+) and the Pages Router (still supported), and includes a complete webhook route handler so your server receives signed delivery events.

The embed itself is a web component and runs in the browser only. The wrapper component (<FlockDonate>) must therefore be marked as a Client Component in App Router projects, or imported with SSR disabled in Pages Router projects.

Step 1: Generate sandbox keys

In the 1st Flock Hub:

  1. Open Donations Embed → Keys.
  2. Click Create key pair.
  3. Set environment to Sandbox and name it (e.g. My Next.js app - sandbox).
  4. Copy both:
    • The publishable key (pk_test_…) — you will paste this in the browser-side component.
    • The secret key (sk_test_…) — you will paste this in the server-side webhook handler.

Then, in Donations Embed → Webhooks, configure your endpoint URL (you will set this up in Step 4 below) and copy the signing secret that the Hub returns.

Step 2: Install the wrapper and the Node SDK

npm install @1st-flock/donations-embed @1st-flock/donations-embed-react @1st-flock/donations-embed-node
# or
pnpm add @1st-flock/donations-embed @1st-flock/donations-embed-react @1st-flock/donations-embed-node

The wrapper handles the browser embed; the Node SDK handles the server-side webhook verifier.

Set environment variables in .env.local:

NEXT_PUBLIC_FLOCK_PUBLISHABLE_KEY=pk_test_REPLACE_ME
FLOCK_SECRET_KEY=sk_test_REPLACE_ME
FLOCK_WEBHOOK_SECRET=whsec_REPLACE_ME

The NEXT_PUBLIC_ prefix exposes the publishable key to the browser. Secret keys and the webhook signing secret stay server-side.

Step 3: Add the embed to a page

App Router

Mark the page (or just the component using the embed) as a Client Component:

app/give/page.tsx:

"use client";

import { FlockDonate } from "@1st-flock/donations-embed-react";

export default function GivePage() {
  return (
    <main>
      <h1>Give to Example Church</h1>
      <p>Thank you for partnering with us.</p>

      <FlockDonate
        publishableKey={process.env.NEXT_PUBLIC_FLOCK_PUBLISHABLE_KEY!}
        mode="inline"
      />
    </main>
  );
}

If you want the rest of the page server-rendered (for SEO) and only the embed on the client, factor the embed into its own Client Component:

app/give/donate-form.tsx:

"use client";

import { FlockDonate } from "@1st-flock/donations-embed-react";

export function DonateForm() {
  return (
    <FlockDonate
      publishableKey={process.env.NEXT_PUBLIC_FLOCK_PUBLISHABLE_KEY!}
      mode="inline"
    />
  );
}

app/give/page.tsx:

import { DonateForm } from "./donate-form";

export default function GivePage() {
  return (
    <main>
      <h1>Give to Example Church</h1>
      <p>Thank you for partnering with us.</p>
      <DonateForm />
    </main>
  );
}

Pages Router

Use a dynamic import with ssr: false:

pages/give.tsx:

import dynamic from "next/dynamic";

const FlockDonate = dynamic(
  () => import("@1st-flock/donations-embed-react").then((mod) => mod.FlockDonate),
  { ssr: false },
);

export default function GivePage() {
  return (
    <main>
      <h1>Give to Example Church</h1>
      <FlockDonate
        publishableKey={process.env.NEXT_PUBLIC_FLOCK_PUBLISHABLE_KEY!}
        mode="inline"
      />
    </main>
  );
}

The dynamic import with ssr: false keeps <FlockDonate> out of the server bundle, so Next will not try to render it during SSR.

Step 4: Add the webhook route handler

This handler receives signed delivery events from the platform, verifies the signature, and dispatches per event type. Run a tunnel to your dev machine while testing locally — the platform's webhook delivery cannot reach localhost.

App Router

app/api/webhooks/flock/route.ts:

import {
  WebhookFormatError,
  WebhookSignatureError,
  WebhookTimestampError,
  verifyWebhook,
} from "@1st-flock/donations-embed-node";
import { NextResponse } from "next/server";

const SECRET = process.env.FLOCK_WEBHOOK_SECRET!;

export async function POST(request: Request): Promise<Response> {
  const signature = request.headers.get("flock-signature");
  if (!signature) {
    return new NextResponse("missing flock-signature header", { status: 400 });
  }

  // CRITICAL: read the raw body bytes. Do NOT call request.json() — re-serialising
  // the JSON would change the bytes and the signature would not match.
  const body = Buffer.from(await request.arrayBuffer());

  let event: ReturnType<typeof verifyWebhook>;
  try {
    event = verifyWebhook(body, signature, SECRET);
  } catch (err) {
    if (
      err instanceof WebhookSignatureError ||
      err instanceof WebhookTimestampError
    ) {
      // Always 401 for signature failures — never confirm which check failed.
      return new NextResponse("verification failed", { status: 401 });
    }
    if (err instanceof WebhookFormatError) {
      return new NextResponse("malformed delivery", { status: 400 });
    }
    throw err;
  }

  // Dedup by event.id — delivery is at-least-once.
  // (Implement alreadyProcessed/recordEvent against your database.)
  const processed = await alreadyProcessed(event.id);
  if (processed) {
    return new NextResponse(null, { status: 204 });
  }
  await recordEvent(event.id);

  switch (event.type) {
    case "donation.completed":
      await persistReceipt(event.data.object);
      break;
    case "donation.refunded":
      await reverseReceipt(event.data.object);
      break;
    case "recurring.activated":
      await recordNewSubscription(event.data.object);
      break;
    case "key.frozen":
      await alertOnCall(event.data.object);
      break;
    default:
      // Unhandled event types are still valid deliveries — return 204
      // so the platform does not retry.
      break;
  }

  return new NextResponse(null, { status: 204 });
}

// Force the route to run dynamically (no caching) so each delivery is verified.
export const dynamic = "force-dynamic";

Pages Router

Pages Router gives you the raw body via req only if you opt out of body parsing.

pages/api/webhooks/flock.ts:

import {
  WebhookFormatError,
  WebhookSignatureError,
  WebhookTimestampError,
  verifyWebhook,
} from "@1st-flock/donations-embed-node";
import type { NextApiRequest, NextApiResponse } from "next";

const SECRET = process.env.FLOCK_WEBHOOK_SECRET!;

// Disable Next's automatic body parsing — we need the raw bytes.
export const config = {
  api: { bodyParser: false },
};

async function readRawBody(req: NextApiRequest): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) {
    chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
  }
  return Buffer.concat(chunks);
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse,
): Promise<void> {
  if (req.method !== "POST") {
    res.status(405).end();
    return;
  }

  const signature = req.headers["flock-signature"];
  if (typeof signature !== "string" || !signature) {
    res.status(400).send("missing flock-signature header");
    return;
  }

  const body = await readRawBody(req);

  let event: ReturnType<typeof verifyWebhook>;
  try {
    event = verifyWebhook(body, signature, SECRET);
  } catch (err) {
    if (
      err instanceof WebhookSignatureError ||
      err instanceof WebhookTimestampError
    ) {
      res.status(401).send("verification failed");
      return;
    }
    if (err instanceof WebhookFormatError) {
      res.status(400).send("malformed delivery");
      return;
    }
    throw err;
  }

  // Dedup + dispatch (same logic as App Router example above).
  // ...

  res.status(204).end();
}

Step 5: Test locally

  1. Run a tunnel from your dev machine. Pick any tool that gives you an HTTPS URL pointing at localhost:3000:
    ssh -R 80:localhost:3000 serveo.net
    # or
    cloudflared tunnel --url http://localhost:3000
    
  2. Configure the public URL in Donations Embed → Webhooks in the Hub. Subscribe to at least donation.completed. The Hub returns a signing secret on save — copy it into FLOCK_WEBHOOK_SECRET in .env.local.
  3. Restart your Next.js dev server so the new env var is picked up.
  4. Open http://localhost:3000/give in a browser.
  5. Walk through a sandbox donation:
FieldValue
Card number4111 1111 1111 1111
Expiry10/29 (any future date works)
CVV123 (any 3 digits work)
ZIPAny valid postal code (no AVS validation in sandbox)

The same canonical sandbox card is documented under Getting Started → Sandbox testing along with the failure-path tester (4000 0000 0000 0002).

  1. Watch your Next.js server console — you should see the webhook arrive a second or two after the donation completes.
  2. Open the Hub's Donations tab to confirm the donation appears with a TEST badge.

Step 6: Switch to live

When ready for production:

  1. Generate a live key pair in the Hub.
  2. Update NEXT_PUBLIC_FLOCK_PUBLISHABLE_KEY to the pk_live_… value.
  3. Update FLOCK_SECRET_KEY to the sk_live_… value.
  4. Configure your production webhook URL in the Hub. The Hub returns a new signing secret; update FLOCK_WEBHOOK_SECRET to that value.
  5. Re-deploy.

Customisation

For React props, event handlers, themes, and the imperative open / close pattern, see Integration Examples → React app. The wrapper API is identical across React frameworks.

For server-side donation operations (refunds, listing, recurring management), see Server SDKs → TypeScript / Node.

Common pitfalls

Note: Module not found: Can't resolve '@1st-flock/donations-embed-react' during build. You forgot to install one of the peer dependencies. Run npm install @1st-flock/donations-embed @1st-flock/donations-embed-react.

Note: window is not defined during build / SSR. Your component using <FlockDonate> is being server-rendered. Add "use client"; at the top of the file (App Router) or use dynamic(..., { ssr: false }) (Pages Router).

Note: Webhook signatures fail with "signature mismatch" but the secret is correct. You called request.json() somewhere before verifying. Re-serialising JSON changes the bytes — verify against the raw bytes only. Use await request.arrayBuffer() in App Router, or read from req directly with bodyParser: false in Pages Router.

Note: Cannot find module 'crypto' in the browser bundle. You imported the Node SDK in a client component. Server-side imports (the Node SDK, the webhook verifier) belong in route handlers / server components only. Client components only need @1st-flock/donations-embed-react.

Note: The webhook handler returns 200 but the Hub shows my deliveries as failed. The platform requires a 2xx response WITH the body emptied or matching its budget — return 204 (No Content) explicitly so there is no ambiguity.

Next steps

Developer Portal