Docs
Docs
Guides

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

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); every customer domain forwards there with the original Host header.

# .env.local (server only: no NEXT_PUBLIC_ prefix)
APEXLY_API_KEY=sk_test_…
APEXLY_SIGNING_SECRET=whsec_…
// 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.

// 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;
  }
}
// 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).

// 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

// 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.

// 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. apexly.domains.diagnose(id) runs every check live and names the one to fix first (why isn't it live?).

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 to see the exact headers your app will get. Without the SDK, the Next.js guide has the same checks in plain node:crypto.

What to read next

Was this helpful?