# Next.js quickstart

Let your customers bring their own domain to a Next.js App Router app in four files. A Server Action creates the domain, proxy.ts checks every request, a Server Component finds the tenant by Host.

## Install

```bash
npm install @apexly/node
```

Put a key and your signing secret in `.env.local`. Use a test key first: test domains never contact a certificate authority and reach `active` on a ten-second timer. Set your app's origin once (Settings, or step 2 of the [Quickstart](/docs/quickstart)); every customer domain forwards there with the original `Host` header.

```bash
# .env.local (server only: no NEXT_PUBLIC_ prefix)
APEXLY_API_KEY=sk_test_…
APEXLY_SIGNING_SECRET=whsec_…
```

```ts
// lib/apexly.ts
import "server-only";
import { Apexly } from "@apexly/node";

export const apexly = new Apexly({ apiKey: process.env.APEXLY_API_KEY! });
```

## Create the domain from a Server Action

Your customer types their hostname in your settings page. The action creates the domain, stores it with the tenant, and returns the connect URL: the hosted page that walks them through their DNS provider's steps. Calling it twice with the same hostname returns the same domain.

```tsx
// app/settings/domain/actions.ts
"use server";
import { ApexlyError } from "@apexly/node";
import { apexly } from "@/lib/apexly";
import { currentTenant, saveTenantDomain } from "@/lib/tenants";

export async function addDomain(_prev: unknown, form: FormData) {
  const tenant = await currentTenant();
  try {
    const domain = await apexly.domains.create({
      hostname: String(form.get("hostname") ?? ""),
      metadata: { tenant: tenant.id },
    });
    await saveTenantDomain(tenant.id, { domainId: domain.id, hostname: domain.hostname });
    return { connectUrl: domain.connect_url, record: domain.dns.cname ?? null };
  } catch (e) {
    if (e instanceof ApexlyError) return { error: e.message, fix: e.fix };
    throw e;
  }
}
```

```tsx
// app/settings/domain/page.tsx
"use client";
import { useActionState } from "react";
import { addDomain } from "./actions";

export default function DomainSettings() {
  const [state, action, pending] = useActionState(addDomain, null);
  return (
    <form action={action}>
      <label htmlFor="hostname">Your domain</label>
      <input id="hostname" name="hostname" placeholder="careers.acme.com" required />
      <button disabled={pending}>Connect</button>
      {state && "error" in state && <p role="alert">{state.error} {state.fix}</p>}
      {state && "connectUrl" in state && (
        <p>
          Almost there: <a href={state.connectUrl}>finish connecting your domain</a>.
        </p>
      )}
    </form>
  );
}
```

`domain.dns.cname` is the one record a subdomain needs (`{ name: "careers", value: "d-….cname.apexly.dev" }`) if you would rather show it yourself. An apex domain (`acme.com`) gets `domain.dns.apex` instead. The connect page handles both.

## Check every request in proxy.ts

Requests for a customer hostname must come through Apexly. The edge signs `<timestamp>.<host>` with your signing secret; `verifyOrigin` checks it, accepts a port or upper case in `Host`, and rejects anything older than five minutes. `proxy.ts` runs before every route (it was `middleware.ts` before Next.js 16; on Next.js 15 add `runtime: "nodejs"` to its `config`).

```ts
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { verifyOrigin } from "@apexly/node";

// 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 = verifyOrigin(
    {
      host,
      timestamp: req.headers.get("x-apexly-timestamp"),
      signature: 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).*)" };
```

Rotating the secret? Pass both for the 24-hour window: `[process.env.APEXLY_SIGNING_SECRET!, process.env.APEXLY_SIGNING_SECRET_PREVIOUS!]`. Any `origin_headers` you set on the domain (say `X-Tenant-Id`) are trustworthy only on requests that pass this check.

## Find the tenant from the Host header

```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+$/, "").toLowerCase();
  const tenant = await tenantByHost(host);
  if (!tenant) notFound();
  return <h1>Careers at {tenant.name}</h1>;
}
```

Build absolute URLs from the request host and `x-forwarded-proto`, not a fixed base URL. Server Actions work on custom domains unchanged: Apexly keeps `Origin` and `Host` as the visitor sent them.

## Know when it's live

Instead of polling, take the `domain.active` webhook. Verify `Apexly-Signature` over the raw body before parsing it.

```ts
// app/webhooks/apexly/route.ts
import { verifyWebhook } from "@apexly/node";
import { markDomainLive } from "@/lib/tenants";

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

Set the endpoint in Settings → Webhooks. To check a domain on demand, `await apexly.domains.verify(id)` queues a DNS check right away; `apexly.domains.get(id)` returns its `status` and, when it is stuck, a `status_reason` with its own [troubleshooting page](/docs/troubleshooting/dns_record_missing). `apexly.domains.diagnose(id)` runs every check live and names the one to fix first ([why isn't it live?](/docs/troubleshooting/domain-doctor)).

## Try it

With the test key, add `careers.acme.com` from your settings page: the domain walks `pending_dns` → `verifying` → `issuing` → `active` in about thirty seconds and the webhook fires. Point a live domain at the [sandbox origin](/docs/guides/test-mode) to see the exact headers your app will get. Without the SDK, the [Next.js guide](/docs/guides/nextjs) has the same checks in plain `node:crypto`.

- [Webhooks](/docs/webhooks)
- [Domains API](/docs/api/domains)
- [How it works](/docs/concepts/domain-states)
