Skip to content
Draft, under reviewUpdated 2026-06-13
On this page

A contact form is the smallest piece of your site that takes untrusted input from a stranger and turns it into a real-world action: an email landing in your inbox. That makes it a security surface, not a formality. This page builds the exact pattern this portfolio ships in app/api/contact/route.ts: one POST route handler that validates, blocks bots, keeps the Resend key on the server, and refuses to crash when the key is not configured yet.

By the end you will have a working endpoint you can curl, wire to a form, and deploy without leaking anything.

Why a route handler, and why POST

Email is a side effect triggered by untrusted input, so it needs one explicit server boundary where you can validate before you act. A POST route handler is that boundary. Route handlers live only in the app directory and, per the Next.js docs, they are not cached by default. Only GET can opt into caching with export const dynamic = "force-static"; a mutation like sending mail must never be cached, so POST is correct and needs no caching config at all.

Put the email send behind a POST route handler. Validate the body first, construct the mail client second, send last. The handler is your one trusted choke point.

Build it

Get a Resend API key

Create an account at resend.com, then open API Keys and create one with send-only permission. It looks like re_xxxxxxxx. While you are there, add and verify your sending domain under Domains. Until that finishes, Resend lets you send from onboarding@resend.dev, which is enough to test the flow end to end.

Put the key in the environment, server-side only

The key is a privileged token. It belongs in process.env, read inside the handler, and it must never be prefixed with NEXT_PUBLIC_. Anything carrying that prefix is inlined into the browser bundle at build time, which would publish your key to every visitor.

.env.local
# Never commit this file.
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx
 
# Where submissions are delivered. Falls back to your profile email if unset.
CONTACT_TO_EMAIL=you@yourdomain.com
 
# Verified sender. Use onboarding@resend.dev until your domain is verified.
CONTACT_FROM_EMAIL=onboarding@resend.dev

Avoid

.env.local
# Inlined into client JS at build time. Now anyone can read your key.
NEXT_PUBLIC_RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx

Prefer

.env.local
# No prefix: stays on the server, never reaches the browser.
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxx

Add a checked-in .env.example with empty values so collaborators know which variables exist, and keep .env.local in .gitignore.

Validate the input and add a honeypot

Before any mail client exists, decide what a valid submission looks like and reject everything else with a 400. A contact form needs a name, a plausible email, and a non-empty message, all length-capped so nobody posts a megabyte of text.

The honeypot is a hidden field (here, company) that real users never see and never fill in. Bots fill every field they find. If it arrives non-empty, you silently return success so the bot learns nothing, and you send no mail.

app/api/contact/route.ts
import { profile } from "@/lib/content/profile";
 
interface ContactPayload {
  name?: unknown;
  email?: unknown;
  message?: unknown;
  /** Honeypot: must be empty for a legitimate submission. */
  company?: unknown;
}
 
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
 
function isNonEmptyString(value: unknown, max: number): value is string {
  return (
    typeof value === "string" &&
    value.trim().length > 0 &&
    value.length <= max
  );
}
 
export async function POST(request: Request) {
  let payload: ContactPayload;
  try {
    payload = (await request.json()) as ContactPayload;
  } catch {
    return Response.json({ error: "Invalid request body." }, { status: 400 });
  }
 
  // Honeypot tripped: pretend it worked, send nothing.
  if (typeof payload.company === "string" && payload.company.trim() !== "") {
    return Response.json({ ok: true });
  }
 
  const { name, email, message } = payload;
 
  if (!isNonEmptyString(name, 120)) {
    return Response.json({ error: "Please enter your name." }, { status: 400 });
  }
  if (!isNonEmptyString(email, 200) || !EMAIL_RE.test(email)) {
    return Response.json(
      { error: "Please enter a valid email address." },
      { status: 400 },
    );
  }
  if (!isNonEmptyString(message, 5000)) {
    return Response.json(
      { error: "Please enter a message." },
      { status: 400 },
    );
  }
 
  // ...send step goes here (next step)
}

Notice the payload fields are typed as unknown, not string. The body came off the wire, so you cannot trust its shape until isNonEmptyString narrows it. That narrowing (a TypeScript type guard) is what lets the rest of the handler treat the values as strings safely.

Do not trust await request.json() to match your interface. JSON parses into whatever the caller sent. Validate every field, then narrow, before you use it.

Degrade gracefully when the key is unset

Before you build the client, read the configuration. On a fresh clone, in CI, or on a preview deploy, RESEND_API_KEY may simply be missing. The wrong move is to construct new Resend(undefined) and let it throw a 500. The right move is to detect the gap and return a friendly 503 Service Unavailable that tells the visitor to email you directly.

app/api/contact/route.ts (continued)
  const apiKey = process.env.RESEND_API_KEY;
  const to = process.env.CONTACT_TO_EMAIL ?? profile.email;
  const from = process.env.CONTACT_FROM_EMAIL ?? "onboarding@resend.dev";
 
  // Not configured yet: do not 500, tell the client politely.
  if (!apiKey) {
    console.warn(
      "[contact] RESEND_API_KEY not set, skipping send. See .env.example.",
    );
    return Response.json(
      {
        error:
          "The contact form isn't fully configured yet. Please email me directly.",
      },
      { status: 503 },
    );
  }

Send the email

Now that input is valid and the key exists, send. Set replyTo to the visitor so you can reply with one click, but keep from on a domain you verified in Resend. Spoofing from with the visitor's address gets you marked as spam and breaks SPF and DKIM.

app/api/contact/route.ts (continued)
  try {
    const { Resend } = await import("resend");
    const resend = new Resend(apiKey);
 
    const { error } = await resend.emails.send({
      from,
      to,
      replyTo: email,
      subject: `Portfolio message from ${name}`,
      text: `From: ${name} <${email}>\n\n${message}`,
    });
 
    if (error) {
      console.error("[contact] Resend error:", error);
      return Response.json(
        { error: "Failed to send. Please try again or email me directly." },
        { status: 502 },
      );
    }
 
    return Response.json({ ok: true });
  } catch (err) {
    console.error("[contact] Unexpected error:", err);
    return Response.json(
      { error: "Failed to send. Please try again or email me directly." },
      { status: 500 },
    );
  }
}

A few deliberate choices:

  • The import("resend") is dynamic so the module only loads at request time, and the resend package never sits on the hot path of an unrelated build.
  • A delivery failure from Resend is a 502 (a bad response from an upstream service), distinct from an unexpected crash, which is 500.
  • Error bodies are generic. They never echo the caller's input or the underlying error text back over the wire. Detailed diagnostics go to your server logs, not to the client.

Test it

Run the dev server and hit the endpoint directly. With the key set, you should get {"ok":true} and an email; without it, the friendly 503.

terminal
# Valid submission
curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","email":"ada@example.com","message":"Hi there"}'
 
# Honeypot tripped: returns {"ok":true} but sends nothing
curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Bot","email":"b@b.co","message":"spam","company":"ACME"}'
 
# Missing email: returns a 400 with a friendly error
curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Ada","message":"Hi"}'

In your form's client component, the matching hidden honeypot field is one line:

components/contact-form.tsx
{/* Hidden from humans, irresistible to bots. Keep it out of the tab order. */}
<input
  type="text"
  name="company"
  tabIndex={-1}
  autoComplete="off"
  aria-hidden="true"
  className="hidden"
/>

Where this goes next

The handler sends plain text, which is rugged and always deliverable. When you want branded, formatted mail, render the body from a template instead of building strings inline.

Sources

  • Next.js docs01-app/01-getting-started/15-route-handlers.md