Building blocks
Frameworks
Pick your stack
Find how your site is built, then use the way in the third column. Sending from a server keeps the key out of the page and lets you pass the visitor’s real IP on.
| Your site | Send leads with | Key in the page | Read |
|---|---|---|---|
Static sites Plain HTML, Astro, Hugo, Eleventy, Jekyll | The hosted form script, a fetch from the page, or a function on your host | Only without a function | |
Next.js App Router, Pages Router | A server action or route handler | No | |
React apps Vite, Create React App, any single-page app | Your own API route or serverless function; the page posts to it | No | |
Full-stack frameworks TanStack Start, React Router, Nuxt, SvelteKit, Astro with an adapter | A server function or endpoint, the same shape as Next.js | No | |
Cloudflare Workers, Pages Functions | A Worker or Pages Function that adds the key and forwards the request | No | |
Any backend Rails, Django, Laravel, Express, Go, PHP | One HTTP POST from your form handler | No | |
Site builders Builders that let you add a script tag | The hosted form script, popup, slide-in or inline | Yes, and that’s safe |
The request every framework sends
However the site is built, this is what reaches 1DB. Put the three values in environment variables; the website’s Integrate tab and npx @1db/cli init both give you them under these names. Set VISITOR_IP_HEADER yourself, from the host table below: the route refuses to start without it.
// The one header YOUR host sets to the visitor's IP (table below): x-real-ip on Vercel,
// cf-connecting-ip on Cloudflare, x-nf-client-connection-ip on Netlify. Never fall back to
// another: a visitor can send any host's header.
const IP_HEADER = process.env.VISITOR_IP_HEADER;
if (!IP_HEADER) throw new Error("Set VISITOR_IP_HEADER to your host's visitor IP header");
// A server route the page posts its form to, as JSON
export async function POST(request: Request) {
// The page makes submission_id once, when the form loads (crypto.randomUUID())
const body: unknown = await request.json().catch(() => null);
const fields = ["submission_id", "email", "message"] as const;
if (!body || typeof body !== "object" || fields.some((f) => typeof (body as Record<string, unknown>)[f] !== "string")) {
return Response.json({ error: "Send submission_id, email and message as JSON strings" }, { status: 400 });
}
const { submission_id, email, message } = body as Record<(typeof fields)[number], string>;
const visitorIp = request.headers.get(IP_HEADER);
if (!visitorIp) console.warn(`No ${IP_HEADER} header: is VISITOR_IP_HEADER right for this host?`);
const visitorUserAgent = request.headers.get("user-agent");
let res: Response;
try {
res = await fetch(`${process.env.DB_API_ENDPOINT}/v1/leads`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.DB_API_KEY!,
"X-1DB-Contract": "2",
// Sent only when known: without the IP, 1DB records your server's address and that
// lead shares your server's rate limit
...(visitorIp ? { "X-1DB-Client-IP": visitorIp } : {}),
...(visitorUserAgent ? { "X-1DB-Client-UA": visitorUserAgent } : {}),
},
body: JSON.stringify({ formSlug: process.env.DB_FORM_SLUG, submission_id, email, message }),
signal: AbortSignal.timeout(10_000),
});
} catch {
// 1DB wasn't reached in 10 seconds. The page keeps its submission_id, so resending can't store the lead twice.
return Response.json({ error: "Couldn't send the form. Try again." }, { status: 503 });
}
// 200: stored. 400 with `fields`: the visitor can fix those fields (`suggestions` may offer
// fixes), so pass them back. Anything else (a wrong key or form slug, 1DB down) is yours to
// fix: log it, and the page gets a plain error.
const answer = await res.json().catch(() => null);
if (res.ok) return Response.json({ ok: true });
if (res.status === 400 && answer?.fields) {
const error = typeof answer.error === "string" ? answer.error : "Check the highlighted fields.";
return Response.json({ error, fields: answer.fields, suggestions: answer.suggestions }, { status: 400 });
}
console.error("1DB answered", res.status, answer);
return Response.json({ error: "Couldn't send the form. Try again." }, { status: 502 });
}Passing the visitor on
A request from your server carries your server’s address. Pass the visitor’s IP and browser in the two X-1DB-Client-* headers, or every lead looks like it came from the same data center and shares one rate limit.
Read the header your host sets itself, named in VISITOR_IP_HEADER so each deployment picks its own. Don’t take the first entry of x-forwarded-for on a host that appends to it rather than replacing it: a visitor can put any address there. Behind your own proxy, use the last entry, the one your proxy added.
| Host | Visitor IP header |
|---|---|
| Cloudflare Workers and Pages | cf-connecting-ip |
| Vercel | x-real-ip |
| Netlify | x-nf-client-connection-ip |
Is the key safe in the page?
Yes. An ingest key can only send leads to its own website: it can’t read, change or delete anything, and requests are rate-limited per visitor. The worst anyone can do with it is send you junk, the same as filling in the form by hand. Revoke it in the dashboard if that happens.
Building a new site
When you add a site that isn’t live yet, 1DB asks which framework and host you’ll use and suggests pairings that work well together, like Next.js on Vercel or Astro on Cloudflare. That choice goes into the build prompt 1DB writes for your coding agent, so the site is built with its form already wired to 1DB.