Technical guide · 1,981 words
How to Build a Contact Form for Your Website with Ease
A contact form for a website needs more than a few input fields: submissions need validation, durable storage, a safe route to an inbox, and a way to reply. A straightforward architecture is to let your website own the visitor-facing form, send its s
A contact form for a website needs more than a few input fields: submissions need validation, durable storage, a safe route to an inbox, and a way to reply. A straightforward architecture is to let your website own the visitor-facing form, send its submission through a private server route, store the record in Dashier, and use a source-level notification hook to deliver a plain-text email. This walkthrough shows the complete path without placing an API key or mail credential in browser code.
What you are building
The browser will post a visitor’s name, email, subject, and message to a server route on your site. That route validates the values and creates a row through Dashier’s per-source REST API. When the row is committed, an enabled Dashier hook selects a saved template and sends the message to a fixed inbox through Resend. The visitor never receives the Dashier API key or the Resend secret.
Dashier supplies the data source, generated record-management screen, REST API, and record-created email hook. It does not generate or host your public website contact form; you write that small UI in the framework your site uses. The current hook event is record.created, with reusable plain-text templates. It is not an arbitrary workflow engine, and it does not provide HTML email templates or attachments.
Before you begin
You need a Dashier data source, permission to create a project API key and edit the source’s API policy, a server-rendered or server-routed website, and a Resend account with a domain you control. Resend requires adding and verifying at least one owned sending domain before sending; see its verified domains guide. A sending subdomain is one option Resend recommends for reputation separation, but choose a domain configuration appropriate to your organization.
Step 1: Model contact submissions in Dashier
In Dashier, create a data source such as Contact submissions. Add fields with stable keys that match the payload your website will send: name (text), email (email), subject (text), and message (textarea). The keys matter: the REST API validates against the source schema, and the email template refers to those same keys.
Keep the model intentionally small. Do not collect personal information simply because the form has room for another field. The new-row email may contain every value you place in the template, so decide which details belong in the shared team inbox and which should stay in the dashboard.
Step 2: Protect the create endpoint
Create a project API key with the write scope. Open the source’s API endpoint settings and enable POST with the Authenticated policy. The key then authenticates the server-to-server create request. Avoid a Public create policy for a contact form unless you have intentionally assessed anonymous access and placed appropriate abuse controls in front of it.
Configure a create rate limit for the endpoint. This protects the data-creation API from excessive requests; it is separate from the notification hook’s limit of 50 successful emails per rolling hour. Keep the API key in the website’s server-side environment, not a variable prefixed with NEXT_PUBLIC_, not a static JavaScript bundle, and not an HTML form field.
Step 3: Set server environment variables
On the website deployment, configure DASHIER_API_ORIGIN to the origin of your Dashier deployment and DASHIER_CONTACT_API_KEY to the write-scoped key. On the Dashier server deployment, configure RESEND_API_KEY and RESEND_FROM_EMAIL. The latter must be a sender address on your verified Resend domain. The two environments have different responsibilities: your site key creates a row, while the Resend key stays only on the Dashier server that sends mail.
Do not commit secret values. In local development, put them in your framework’s ignored local environment file or a secret manager. In production, use the hosting provider’s server environment settings. Dashier never asks you to paste provider credentials into its browser UI and does not return them from its settings API.
Step 4: Create a template and hook
Open the data source and choose Email hooks. Add a plain-text template with a subject such as “New website enquiry from {{name}}”. In the body, include only the fields the recipient needs. For example, your template may contain the name, email, subject, and message variables, followed by the record identifier. The editor validates template variables against the source’s real field keys, so an unknown token is rejected instead of silently producing an incomplete message.
Create a hook for a new row, choose the template, and enter the fixed inbox address where submissions should arrive. Optionally select the source’s email field as Reply-to. The recipient is a saved setting; the form payload cannot choose an arbitrary recipient. This is deliberate: it prevents a public form from being used as an open mail relay. A malformed reply-to value makes that delivery fail rather than changing the destination.
The hook is not active until the server has both Resend settings. If they are missing, delivery history records the event as blocked; you can configure the provider and process a bounded retry batch later. The source’s Email hooks tab shows recent delivery status without copying the message text into the delivery log.
Step 5: Build the visitor-facing form
The client form needs accessible labels, browser-level validation, and a useful success or error state. It submits to your own same-origin route, never directly to the Dashier API. The following React component illustrates the client side; adapt its styling and framework conventions to your site.
"use client";
import { useState } from "react";
import type { FormEvent } from "react";
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("sending");
const form = event.currentTarget;
const values = Object.fromEntries(new FormData(form).entries());
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(values),
});
if (!response.ok) throw new Error("Submission failed");
form.reset();
setStatus("sent");
} catch {
setStatus("error");
}
}
return (
<form onSubmit={submit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required maxLength={120} />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" autoComplete="email" required maxLength={254} />
<label htmlFor="subject">Subject</label>
<input id="subject" name="subject" required maxLength={160} />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required maxLength={5000} rows={6} />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send message"}
</button>
{status === "sent" && <p role="status">Thanks. Your message has been received.</p>}
{status === "error" && <p role="alert">We could not submit your message. Please try again.</p>}
</form>
);
}This component sends only to your website’s /api/contact route. Its browser bundle contains no secret. A real public form still needs abuse protection appropriate to your site: configure Dashier’s authenticated API rate limit and add site-level rate limiting, request-size bounds, and a spam strategy before exposing it widely. Email hooks can reduce email volume after the 50-success hourly ceiling is reached, but that ceiling does not replace request throttling or form abuse controls.
Step 6: Validate and create the row on the server
Create a POST handler at /api/contact. Validate the JSON again on the server—browser validation can be bypassed—and forward only the expected fields. Keep the key and Dashier origin out of the client. This Next.js Route Handler example keeps provider and API errors generic for the visitor and never echoes an API key.
import { NextResponse } from "next/server";
function validEmail(value: unknown): value is string {
return typeof value === "string" && value.length <= 254 &&
/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(value);
}
export async function POST(request: Request) {
const body: unknown = await request.json().catch(() => null);
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
const input = body as Record<string, unknown>;
if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 120 ||
!validEmail(input.email) || typeof input.subject !== "string" ||
!input.subject.trim() || input.subject.length > 160 ||
typeof input.message !== "string" || !input.message.trim() || input.message.length > 5000) {
return NextResponse.json({ error: "Check the form fields and try again." }, { status: 400 });
}
const origin = process.env.DASHIER_API_ORIGIN?.replace(/\/+$/, "");
const key = process.env.DASHIER_CONTACT_API_KEY;
if (!origin || !key) {
return NextResponse.json({ error: "Contact form is not configured." }, { status: 503 });
}
try {
const response = await fetch(`${origin}/api/v1/contact-submissions`, {
method: "POST",
headers: { "Content-Type": "application/json", "x-api-key": key },
body: JSON.stringify({
name: input.name.trim(), email: input.email.trim(),
subject: input.subject.trim(), message: input.message.trim(),
}),
cache: "no-store",
});
if (!response.ok) {
return NextResponse.json({ error: "We could not save your message." }, { status: 502 });
}
return NextResponse.json({ ok: true }, { status: 201 });
} catch {
return NextResponse.json({ error: "We could not save your message." }, { status: 502 });
}
}The example’s keys and route are illustrative names, not preconfigured credentials or an actual Dashier deployment. Keep your API key in the server’s secret store. If your deployment needs timeouts, structured logs, or stricter validation, add those without logging the submitted message or secret header. Before production, put a rate limiter or gateway in front of this public website route; the Dashier API’s authenticated policy and source limits are an additional boundary, not a replacement for abuse handling on your public site.
Step 7: Test end to end
First create a controlled test submission and confirm the row appears in the Dashier source. Then inspect the Email hooks delivery history and the Resend dashboard. Confirm the sender domain is verified and check the destination mailbox, including spam handling. Try a missing required field, an invalid email, a paused hook, and a provider configuration with a sender error. A failed email should not erase a successfully created record.
The delivery panel processes at most 25 queued or retryable items per request. Bulk CSV/JSON imports use that bound so one large import does not hold a request open while attempting every email. If more remain, review the source’s Email hooks tab and process another batch. Each hook permits at most 50 successful sends in a rolling hour; items blocked by the limit can be retried later. Resend’s email API accepts an Idempotency-Key header, and its idempotency guide describes a 24-hour duplicate-request window. That provider window is helpful for retries, not a permanent exactly-once guarantee.
Troubleshooting checklist
If the hook is absent, confirm you are an Owner, Admin, or Editor and that the database migration for notification hooks has been applied. If a delivery is blocked because the provider is not configured, add the two server-only Resend variables and retry from Email hooks. If Resend rejects a request, confirm the sender is on a verified domain and the API key belongs to the account that owns it. If the row exists but no email arrives, inspect the delivery status first; provider acceptance does not guarantee mailbox delivery.
If the API request returns 401 or 403, check the API key, write scope, source endpoint’s POST enabled state, and Authenticated policy. If it returns validation errors, compare the JSON keys and value types with the source schema. If the browser reports success but no record exists, make the website route treat non-success API responses as errors and avoid returning a success response before Dashier confirms the row was created.
A maintainable contact-form checklist
- Keep the visitor form on your own site and submit to a same-origin server route.
- Use a source model with only necessary fields and stable keys.
- Keep Dashier and Resend credentials server-only.
- Configure an authenticated write endpoint, a write-scoped key, and request limits.
- Send email only to a fixed, operator-configured inbox; use reply-to for the visitor’s address.
- Use a plain-text template and avoid copying unnecessary sensitive data into email.
- Test database persistence, provider delivery, failure paths, bulk imports, and retry behavior.
For product-specific setup, see Dashier’s record-created notification guide, API reference, and integration guide. For sender prerequisites, consult Resend’s verified domains documentation.