# Next.js

Serve every customer domain from one Next.js app. Find the tenant from the Host header and trust only requests Apexly signed.

## How requests arrive

Your customer's visitors reach `careers.acme.com`; Apexly terminates TLS and forwards the request to your origin with the original `Host` header and four headers of its own. Your app needs two things: map the host to a tenant, and refuse any request for a host you don't own that Apexly did not sign.

```http
Host: careers.acme.com
X-Forwarded-Proto: https
X-Apexly-Domain-Id: dom_8f3a
X-Apexly-App-Id: app_hf_2k9x
X-Apexly-Timestamp: 1760587971
X-Apexly-Signature: 9a3f…
```

## Verify the signature

`X-Apexly-Signature` is the hex HMAC-SHA256 of `<timestamp>.<host>` keyed with your signing secret (`whsec_…`, Settings → Signing secret). Put the secret in `APEXLY_SIGNING_SECRET` and add this helper. It is tested against the shared vectors on every build.

```ts check="origin"
// lib/apexly.ts
import { createHmac, timingSafeEqual } from "node:crypto";

/** True when X-Apexly-Signature is valid for this Host (the edge signs `${timestamp}.${host}`). */
export function verifyApexlyOrigin(
  host: string,
  timestamp: string | null | undefined,
  signature: string | null | undefined,
  secret: string,
  now = Math.floor(Date.now() / 1000),
): boolean {
  if (!timestamp || !signature || !/^\d{1,12}$/.test(timestamp)) return false;
  if (Math.abs(now - Number(timestamp)) > 300) return false;
  const name = host.replace(/:\d+$/, "").toLowerCase();
  const expected = createHmac("sha256", secret).update(`${timestamp}.${name}`).digest();
  const got = Buffer.from(signature, "hex");
  return got.length === expected.length && timingSafeEqual(got, expected);
}
```

Then check it in `proxy.ts` (called `middleware.ts` before Next.js 16), which runs before every route:

```ts
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { verifyApexlyOrigin } from "./lib/apexly";

// Hosts you serve yourself; everything else must come through Apexly.
const OWN_HOSTS = new Set(["app.hireflow.com", "localhost:3000"]);

export function proxy(req: NextRequest) {
  const host = req.headers.get("host") ?? "";
  if (OWN_HOSTS.has(host)) return NextResponse.next();
  const ok = verifyApexlyOrigin(
    host,
    req.headers.get("x-apexly-timestamp"),
    req.headers.get("x-apexly-signature"),
    process.env.APEXLY_SIGNING_SECRET!,
  );
  if (!ok) return new NextResponse("Unknown host", { status: 421 });
  return NextResponse.next();
}

export const config = { matcher: "/((?!_next/static|_next/image|favicon.ico).*)" };
```

## Find the tenant

Read the host in a Server Component, route handler or Server Action and look the tenant up by it. Store the hostname with the tenant when you create the domain (or put your tenant id in `metadata` and store the domain id).

```tsx
// app/page.tsx
import { headers } from "next/headers";
import { notFound } from "next/navigation";
import { tenantByHost } from "@/lib/tenants";

export default async function Home() {
  const host = (await headers()).get("host")!.replace(/:\d+$/, "");
  const tenant = await tenantByHost(host);
  if (!tenant) notFound();
  return <h1>Careers at {tenant.name}</h1>;
}
```

## Allow your customers' hostnames

Next.js does not block unknown hosts, so nothing else is needed. If you build absolute URLs, build them from the request host and `x-forwarded-proto`, not from a fixed base URL. Server Actions compare `Origin` with `Host`; Apexly keeps both as the visitor sent them, so they work on custom domains unchanged.

## Receive webhooks

Add a route handler that verifies `Apexly-Signature` over the raw body before parsing it. The `verifyApexlySignature` function is on the [Webhooks](/docs/webhooks#verify-the-signature) page.

```ts
// app/webhooks/apexly/route.ts
import { verifyApexlySignature } from "@/lib/apexly-webhooks";

export async function POST(req: Request) {
  const raw = await req.text();
  if (!verifyApexlySignature(req.headers.get("apexly-signature") ?? undefined, raw, process.env.APEXLY_SIGNING_SECRET!)) {
    return new Response(null, { status: 400 });
  }
  const event = JSON.parse(raw);
  if (event.type === "domain.active") await markLive(event.data.domain.metadata.customer);
  return new Response(null, { status: 204 });
}
```

## Try it

Create a test key and a test domain with the [Quickstart](/docs/quickstart): test domains reach `active` on a ten-second timer. Point a live domain at the [sandbox origin](/docs/guides/test-mode) to see the exact headers your app will get, with `signature_valid` worked out for you.

- [Webhooks](/docs/webhooks)
- [Test mode](/docs/guides/test-mode)
- [How it works](/docs/concepts/domain-states)
