The moment a contact form needs to reply to the person who filled it out, Webflow's native notifications reach their limit, and SendGrid can come in to help.
Webflow's built-in form system collects submissions and forwards a plain-text email to the site owner. That works for simple "notify me" flows. Still, the moment you need a branded confirmation email back to the user, server-side field validation, or routing logic that sends different inquiries to different inboxes, you're past what native form notifications can do.
Customizing those notification emails only takes you so far, as I covered in my guideto customizing Webflow formsubmissions. The real ceiling is that Webflow's form handler doesn't give you control over the email body sent back to the submitter.
In this guide, we build a complete contact form system. By the end, you will have:
- A React form component with controlled inputs and loading states
- A server-side route handler with per-field validation
- A honeypot field for basic bot filtering, dual HTML email sends (a confirmation to the submitter and a structured alert to your team), and the CORS configuration your route handler needs when the form lives on a separate Webflow site calling into your Webflow Cloud endpoint.
What do you need to build a contact form with SendGrid on Webflow Cloud?
You need a Webflow Cloud Next.js app, a SendGrid account with a verified sender identity, and three environment variables. The end-to-end setup takes roughly 15 minutes before you write application code.
Here is everything to have in place before starting.
A Webflow Cloud Next.js app with an App Router route handler
You need a Next.js project deployed on Webflow Cloud, or at a minimum, initialized locally with the Webflow CLI. The Webflow Cloud getting-started guide walks through creating a project from a starter template and running the first deployment.
The project needs at least one App Router route handler directory, an app/api/ folder, where the contact form endpoint will live.
If you're building this form as part of a larger app that includes authentication or payments, the guide on building a full-stack app on Webflow Cloud with Supabase, Auth0, and Stripe covers how to layer multiple services in the same project.
Three environment variables
You need three values in your .env.local file for local development and in Webflow Cloud's environment variable settings for production:
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
SENDGRID_FROM_EMAIL=hello@yourdomain.com
CONTACT_FORM_TO_EMAIL=team@yourdomain.com
SENDGRID_FROM_EMAIL is the address that appears as the sender on all emails this contact form sends. It must match your verified sender identity or domain in SendGrid. CONTACT_FORM_TO_EMAIL is your team's inbox, where the internal notification arrives for each new submission.
None of these need a NEXT_PUBLIC_ prefix, because all SendGrid operations in this guide run server-side inside route handlers, never in the browser.
Once these are ready, you can proceed with the integration.
6 steps to build a contact form with SendGrid email delivery on Webflow Cloud
The implementation splits into three distinct pieces:
- A typed
lib/sendgrid.tshelper that wraps SendGrid's v3 Mail Send REST API withfetch() - A route handler at
app/api/contact/route.tsthat validates fields and fires dual email sends - A React form component with loading and error states
The CORS configuration in the route handler is what makes the form callable from a separate Webflow site pointing at your Webflow Cloud API endpoint.
Let's go through each step.
1. Add your SendGrid credentials as environment variables
For local development, add the three variables to .env.local at the root of your Next.js project. Confirm .env.local is listed in your .gitignore before proceeding. Committing API keys to version control is one of the most common and costly developer mistakes I see on production projects:
# .env.local
SENDGRID_API_KEY=SG.xxxxxxxxxxxxxxxxxxxx
SENDGRID_FROM_EMAIL=hello@yourdomain.com
CONTACT_FORM_TO_EMAIL=team@yourdomain.com
For production, navigate to your Webflow Cloud dashboard, open the project, select the environment, and click Environment Variables. Add each variable and toggle Secret for SENDGRID_API_KEY.
Variables marked as secret are encrypted and masked in the Environment Variables dashboard, so collaborators with site access can see your public variables but not your secret ones. Treat build logs as a separate surface and avoid logging secret values yourself.
Verify your sender identity before your first send
SendGrid refuses any send whose from address does not belong to a verified Single Sender or an authenticated domain, and it answers with a 403 rather than a useful hint. This is the step most readers skip, and it is why a correct-looking integration fails on the very first submission. In the SendGrid dashboard open Settings, then Sender Authentication, then Verify a Single Sender, and enter the address you set as SENDGRID_FROM_EMAIL. SendGrid emails a confirmation link to that address, and sends stay blocked until you click it. For production, authenticate the whole sending domain instead, which the deliverability section later in this guide covers.
2. Create the SendGrid fetch helper
Rather than importing @sendgrid/mail, which does not run on Cloudflare Workers due to its dependency on node:http, you call SendGrid's v3 Mail Send REST endpoint directly with fetch().
The Workers runtime supports fetch() natively, making this approach both compatible and simpler than the SDK, with less code and zero additional dependencies.
Create lib/sendgrid.ts in your project:
// lib/sendgrid.ts
export type EmailContent = {
to: string
replyTo?: string
subject: string
text: string
html: string
}
export async function sendEmail(content: EmailContent): Promise<void> {
const apiKey = process.env.SENDGRID_API_KEY
const fromEmail = process.env.SENDGRID_FROM_EMAIL
if (!apiKey) throw new Error('Missing SENDGRID_API_KEY environment variable')
if (!fromEmail) throw new Error('Missing SENDGRID_FROM_EMAIL environment variable')
const body = {
personalizations: [{ to: [{ email: content.to }] }],
from: { email: fromEmail },
reply_to: content.replyTo ? { email: content.replyTo } : undefined,
subject: content.subject,
content: [
{ type: 'text/plain', value: content.text },
{ type: 'text/html', value: content.html },
],
}
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!response.ok) {
const errorBody = await response.text()
throw new Error(`SendGrid API error ${response.status}: ${errorBody}`)
}
}
Notice that apiKey and fromEmail are read inside the function body rather than at the module top level. This is the required pattern for Webflow Cloud. The reply_to field is worth calling out: setting it to the submitter's email address lets your team reply directly to contact form submissions in their email client without manually copying the address.
For a contact form, this is almost always the right behavior, and I set it by default on every project.
A successful send returns HTTP 202 Accepted with an empty body. The function throws on any non-OK status so that callers can catch errors with standard try/catch. The alternative of returning structured error objects instead of throwing is fine too, but throwing is simpler and consistent with how the route handler in the next step handles errors.
If you're building other email flows alongside the contact form, such as order confirmations or password resets, the guide on sending transactional emails from Webflow Cloud with SendGrid, Postmark, and Twilio covers additional fetch-based helper patterns for each service.
3. Build the contact form API route handler
Create app/api/contact/route.ts. This route handler accepts a POST request from the form, validates the incoming fields, fires two emails in parallel (one confirmation to the submitter, one internal notification to your team), and returns a structured JSON response that the form component uses to display success or error states.
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getCloudflareContext } from '@opennextjs/cloudflare'
import { sendEmail } from '@/lib/sendgrid'
type ContactPayload = {
name: string
email: string
message: string
honeypot?: string
}
type FieldErrors = {
name?: string
email?: string
message?: string
}
const MAX_PER_WINDOW = 5
const WINDOW_SECONDS = 60 * 10
// A honeypot only guards the rendered form. This route is a public POST endpoint,
// so cap it server-side or it can be driven in a loop against your SendGrid quota.
async function overRateLimit(request: NextRequest): Promise<boolean> {
const { env } = getCloudflareContext()
const ip = request.headers.get('CF-Connecting-IP') ?? 'unknown'
const bucket = `contact:${ip}`
const used = Number((await env.CONTACT_KV.get(bucket)) ?? '0')
if (used >= MAX_PER_WINDOW) return true
await env.CONTACT_KV.put(bucket, String(used + 1), {
expirationTtl: WINDOW_SECONDS,
})
return false
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function validatePayload(payload: ContactPayload): FieldErrors {
const errors: FieldErrors = {}
if (!payload.name || payload.name.trim().length < 2) {
errors.name = 'Name must be at least 2 characters.'
}
if (!payload.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(payload.email)) {
errors.email = 'A valid email address is required.'
}
if (!payload.message || payload.message.trim().length < 10) {
errors.message = 'Message must be at least 10 characters.'
}
return errors
}
const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS ?? '').split(',').filter(Boolean)
function corsHeaders(origin: string | null): Record<string, string> {
const allowed =
origin && (ALLOWED_ORIGINS.length === 0 || ALLOWED_ORIGINS.includes(origin))
? origin
: ''
return {
'Access-Control-Allow-Origin': allowed || 'null',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
}
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin')
return new NextResponse(null, { status: 204, headers: corsHeaders(origin) })
}
export async function POST(request: NextRequest) {
if (await overRateLimit(request)) {
return NextResponse.json(
{ error: 'Too many submissions. Try again shortly.' },
{ status: 429 }
)
}
const origin = request.headers.get('origin')
const headers = corsHeaders(origin)
const payload = (await request.json()) as ContactPayload
// Honeypot check: if this field is filled, it's a bot.
if (payload.honeypot) {
return NextResponse.json({ success: true }, { headers })
}
const errors = validatePayload(payload)
if (Object.keys(errors).length > 0) {
return NextResponse.json({ errors }, { status: 422, headers })
}
const { name, email, message } = payload
const toEmail = process.env.CONTACT_FORM_TO_EMAIL
if (!toEmail) {
return NextResponse.json({ error: 'Server configuration error.' }, { status: 500, headers })
}
try {
await Promise.all([
// Confirmation to submitter
sendEmail({
to: email,
subject: `Thanks for reaching out, ${escapeHtml(name)}`,
text: `Hi ${name},\n\nWe received your message and will respond within one business day.\n\nYour message:\n${message}`,
html: `
<p>Hi ${escapeHtml(name)},</p>
<p>Thanks for reaching out. We received your message and will respond within one business day.</p>
<blockquote style="border-left:3px solid #ccc;padding-left:12px;color:#555;">
${escapeHtml(message).replace(/\n/g, '<br>')}
</blockquote>
`,
}),
// Internal notification to team
sendEmail({
to: toEmail,
replyTo: email,
subject: `New contact form submission from ${name}`,
text: `Name: ${name}\nEmail: ${email}\nMessage:\n${message}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${escapeHtml(name)}</p>
<p><strong>Email:</strong> <a href="mailto:${encodeURIComponent(email)}">${escapeHtml(email)}</a></p>
<p><strong>Message:</strong></p>
<blockquote style="border-left:3px solid #ccc;padding-left:12px;">
${escapeHtml(message).replace(/\n/g, '<br>')}
</blockquote>
`,
}),
])
return NextResponse.json({ success: true }, { headers })
} catch (error) {
console.error('Contact form send error:', error)
return NextResponse.json(
{ error: 'Failed to send. Please try again.' },
{ status: 500, headers }
)
}
}
A few things are happening here that are worth examining.
(1) First, export const runtime = 'edge' should not be there. Webflow Cloud runs Next.js through the OpenNext Cloudflare adapter, and OpenNext's setup guide tells you to remove that line from every source file, because the adapter does not support the Next.js Edge runtime.
If you copied the directive from an older tutorial, delete it before deploying.
(2) Second, Promise.all fires both email sends in parallel. For a contact form, the user confirmation and team notification are independent operations, so there's no reason to wait for one before starting the other. Running them concurrently cuts the total send time roughly in half, which matters for perceived speed of form response.
(3) Third, the honeypot check returns { success: true } rather than an error when triggered. This is intentional: returning a success response to a bot gives no signal that the honeypot fired, which makes it harder for sophisticated bots to detect and circumvent.
Add ALLOWED_ORIGINS=https://yoursite.webflow.io,https://yourdomain.com to your environment variables if the form will be called from a separate Webflow site. Leave ALLOWED_ORIGINS empty to allow any origin (acceptable during development, not recommended for production contact forms).
4. Add server-side validation and spam protection
The validation logic lives inside the validatePayload function in the route handler, but the email and name regex patterns deserve some explanation. The email regex ^[^\s@]+@[^\s@]+\.[^\s@]+$ is deliberately simple, and it catches the vast majority of malformed addresses without the fragile complexity of RFC 5322-compliant validation, which flags legitimate addresses.
For a contact form, the goal is to prevent accidental typos, not to enforce the email specification exhaustively. If deliverability to a specific address matters, the real check is a send attempt, not a regex.
The honeypot field is a hidden input that human visitors never see or fill, but bots frequently do because they parse the DOM and complete every input. The implementation requires a matching invisible field in your form component, which the next step covers.
I use aria-hidden="true" combined with style={{ display: 'none' }} on the field's wrapper. This keeps the field invisible to screen readers and removes it from the tab order, preventing browser autofill from triggering it. Naming the field something plausible to a bot but irrelevant to humans, such as website or company , increases the catch rate.
Honeypots reliably stop simple bots, but sophisticated scrapers that render JavaScript and mimic human behavior can bypass them. For contact forms with significant traffic or that are targets for spam, layering a honeypot with reCAPTCHA provides stronger coverage.
You can also read about Webflow's own honeypot technique for filtering spam form submissions for comparison with how native Webflow forms handle it. I also add basic rate limiting at the infrastructure level using Cloudflare's built-in Workers rate limiting when projects are particularly spam-prone, but for most contact forms, a honeypot plus reCAPTCHA is sufficient.
5. Build the React contact form component
The contact form component manages three states: default (idle form), loading (submission in progress), and result (success or error message). Using a controlled component pattern here gives you per-field error display from the server's validation response.
Create components/ContactForm.tsx:
// components/ContactForm.tsx
'use client'
import { FormEvent, useState } from 'react'
type FieldErrors = { name?: string; email?: string; message?: string }
type FormState = { status: 'idle' | 'loading' | 'success' | 'error'; errors?: FieldErrors; message?: string }
const API_URL = process.env.NEXT_PUBLIC_CONTACT_API_URL ?? '/api/contact'
export function ContactForm() {
const [state, setState] = useState<FormState>({ status: 'idle' })
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault()
setState({ status: 'loading' })
const form = e.currentTarget
const data = {
name: (form.elements.namedItem('name') as HTMLInputElement).value,
email: (form.elements.namedItem('email') as HTMLInputElement).value,
message: (form.elements.namedItem('message') as HTMLTextAreaElement).value,
honeypot: (form.elements.namedItem('honeypot') as HTMLInputElement).value,
}
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
const json = await res.json()
if (res.ok) {
setState({ status: 'success' })
form.reset()
} else if (res.status === 422 && json.errors) {
setState({ status: 'error', errors: json.errors })
} else {
setState({ status: 'error', message: json.error ?? 'Something went wrong. Please try again.' })
}
} catch {
setState({ status: 'error', message: 'Network error. Please check your connection and try again.' })
}
}
return (
<form onSubmit={handleSubmit} noValidate>
{/* Honeypot field -- hidden from humans, visible to bots */}
<div aria-hidden="true" style={{ display: 'none' }}>
<label htmlFor="honeypot">Leave this field blank</label>
<input type="text" id="honeypot" name="honeypot" tabIndex={-1} autoComplete="off" />
</div>
<div>
<label htmlFor="name">Name</label>
<input type="text" id="name" name="name" required autoComplete="name" />
{state.errors?.name && <p role="alert">{state.errors.name}</p>}
</div>
<div>
<label htmlFor="email">Email</label>
<input type="email" id="email" name="email" required autoComplete="email" />
{state.errors?.email && <p role="alert">{state.errors.email}</p>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required rows={5} />
{state.errors?.message && <p role="alert">{state.errors.message}</p>}
</div>
{state.status === 'success' && (
<p role="status">Your message was sent. We will be in touch within one business day.</p>
)}
{state.status === 'error' && state.message && (
<p role="alert">{state.message}</p>
)}
<button type="submit" disabled={state.status === 'loading'}>
{state.status === 'loading' ? 'Sending...' : 'Send message'}
</button>
</form>
)
}
The NEXT_PUBLIC_CONTACT_API_URL environment variable allows you to point the form at a different URL in different environments. In development, the default /api/contact resolves against localhost:3000.
In production on the same domain, it resolves to your deployed Webflow Cloud app. If the form lives on a separate Webflow site calling your Webflow Cloud app cross-origin, set this to the full URL of your deployed Webflow Cloud contact endpoint: for example, https://app.yourdomain.com/api/contact.
The role="alert" on error paragraphs and role="status" on the success message ensures screen readers announce state changes without requiring focus to move. These are small details, but contact forms are among the most accessibility-sensitive components on most sites because they're the direct conversion path for visitors who need assistance.
6. Connect the form to your Webflow site
If your contact form component lives inside your Webflow Cloud Next.js app, you're already done: the form renders server-side in the same origin as the route handler, no CORS configuration is needed, and you embed the page or mount path from Webflow Cloud onto your Webflow site.
Most Webflow Cloud projects are mounted under a path like /app on the main Webflow site domain, which keeps everything same-origin.
If the form lives elsewhere, either a native Webflow form on your main site or a separate page that calls your Webflow Cloud API cross-origin, two changes are needed.
Adding your Webflow site's domains to theALLOWED_ORIGINSenvironment variable
First, add your Webflow site's domains to the ALLOWED_ORIGINS environment variable in your Webflow Cloud project:
ALLOWED_ORIGINS=https://yoursite.webflow.io,https://yourdomain.com
Second, add custom JavaScript to your Webflow form's submit event that intercepts the native submission and posts to your Webflow Cloud endpoint instead. This replaces what Webflow would normally do with the form data.
If you're not running the React component in a Webflow Cloud-hosted page, a fetch call from Webflow's Embed element handles this correctly. For teams managing form-to-CRM routing from native Webflow forms, the approach to linking Webflow forms to HubSpot using the native app follows the same routing pattern as for native forms.
Deploying your Webflow Cloud
Deploy your Webflow Cloud app with webflow cloud deploy after all changes are committed. Verify the deployment succeeded, open the deployed URL, submit the contact form with your own email and a test message, and confirm both the user confirmation and team notification arrive.
Check the Activity section of your SendGrid dashboard for a record of both sends, each with delivery status and any bounce or spam-filter events.
What causes contact form email delivery to fail on Webflow Cloud?
Most contact form failures on Webflow Cloud trace to one of four root causes:
- The
@sendgrid/mailpackage is being imported instead of using the fetch helper - Environment variables are being read at the module top level
- A leftover
export const runtime = 'edge'directive on the route handler, which the OpenNext adapter does not support - A sender identity that has not been verified in SendGrid
Each of these fails quietly in different ways, which is why they can be slow to diagnose.
Here's how to recognize each one and what to fix.
SendGrid returns 401 Unauthorized after deployment, but not in development
This almost always means process.env.SENDGRID_API_KEY is read at module initialization rather than inside a function body. In development, next dev loads env vars at startup, so module-level reads return the correct value.
In Webflow Cloud production, environment variables are available only at runtime, not at build time: the module loads, process.env.SENDGRID_API_KEY evaluates to undefined, and every subsequent call passes an empty Bearer token to SendGrid.
The fix is to move all process.env reads inside function bodies. The helper in Step 2 does this correctly, since both apiKey and fromEmail are read inside sendEmail(). If you see a 401 despite having the variable set in your Webflow Cloud dashboard, check every file that imports or uses process.env values from SendGrid and confirm they're read inside functions.
A related but distinct failure looks similar. If SendGrid returns 403 Forbidden rather than 401, the key is valid but lacks Mail Send permission. Restricted Access keys need the Mail Send scope toggled on explicitly, and it is easy to miss during key creation.
Contact form submissions succeed, but emails land in spam
Spam filtering almost always traces to missing domain authentication. When you send from a domain that hasn't been authenticated with SendGrid (via DKIM and SPF CNAME records), inbox providers like Gmail and Outlook have no cryptographic proof that SendGrid is authorized to send on your behalf. They apply spam scoring accordingly.
The fix is to complete domain authentication in SendGrid under Settings> Sender Authentication. With Automated Security enabled (the default), SendGrid generates four DNS records: three CNAME records for mail routing and link branding, plus one TXT record for DMARC.
After adding them, click Verify in the SendGrid dashboard. DNS records usually resolve within an hour, but SendGrid notes verification can take up to 48 hours. If verification fails, wait 10 minutes and try again. Once authentication is active, SendGrid's automated security rotates DKIM keys without any downtime or deliverability interruption.
If you're sending order confirmation emails from Webflow with SendGrid from the same domain, one domain authentication setup covers both, since the sending domain is what's authenticated rather than individual API keys or email addresses.
The honeypot is blocking real user submissions
Browser autofill is the most common cause of false positives. If a browser's autofill feature detects the honeypot input by scanning field names, it fills it alongside legitimate fields, causing real submissions to be silently dropped.
Two practices prevent this reliably.
(1) First, set autoComplete="off" on the honeypot input (shown in Step 5).
(2) Second, give the honeypot field a name that sounds like something a bot would want to fill, such as website or company , but that no browser autofill profile would populate based on your other form fields.
If false positives persist, rename the honeypot field to something more obscure. I've also seen issues when password manager browser extensions scan forms aggressively and fill any visible-to-DOM field.
Wrapping the honeypot in a display: none container (as shown in the component) handles most cases, but an additional position: absolute; left: -9999px CSS rule as a belt-and-suspenders measure eliminates the remaining edge cases with certain accessibility overlays.
Extend transactional email across your Webflow Cloud app
Adding error tracking to your Webflow Cloud route handlers takes under 10 minutes once the contact form is working. Our guide on adding Sentry error tracking to a Webflow Cloud app covers the exact setup: Sentry's Next.js wizard, the onRequestError export in instrumentation.ts, and how to add structured context to errors so each captured exception tells you which route failed and what the request contained.
For teams that need the contact form data to flow into a CRM or project management tool rather than stopping at a notification email, see how to connect Webflow forms to HubSpot via Zapier.
Explore Webflow + SendGrid to see how SendGrid connects to Webflow's broader CMS and form data.
Frequently asked questions
Can I use@sendgrid/mailon Webflow Cloud instead of the fetch helper?
No. The @sendgrid/mail package depends on the node:http and node:https modules, which Cloudflare Workers does not provide. Importing it causes a build or runtime error on Webflow Cloud, even if it works correctly in local next dev, because local development runs on full Node.js. The fetch-based helper in Step 2 replaces the SDK entirely and is actually less code, because SendGrid's v3 REST API accepts a plain JSON POST, making the SDK layer unnecessary for contact form use cases.
How do I add reCAPTCHA to a Webflow Cloud contact form?
Add your reCAPTCHA v3 site key as a NEXT_PUBLIC_ environment variable so the client-side form component can initialize it. On the server side, verify the token in the route handler by calling Google's reCAPTCHA Verify API with fetch() before running validatePayload. Verify the returned score (typically use a threshold of 0.5) and reject submissions that score below it with a 422 response. The guide on adding reCAPTCHA spam protection to Webflow forms covers the verification flow in detail.
Do I need CORS headers if the form lives inside my Webflow Cloud app?
No. If the contact form component is a React component rendered within your Webflow Cloud Next.js app and the form posts to /api/contact on the same origin, no CORS configuration is needed. CORS applies only when the requesting page is from a different origin than the API endpoint. The CORS configuration in Step 3, meaning the ALLOWED_ORIGINS variable and the OPTIONS preflight handler , is only necessary when the form is hosted on a separate Webflow site that calls your Webflow Cloud API endpoint from a different origin.




