Nuxt contact form without a server route
Add a contact form to Nuxt that keeps working with nuxt generate: post to Formlane with fetch(), no server/api route, no SMTP. Works with Nuxt UI forms.
The usual Nuxt way to handle a form is a server route such as server/api/contact.post.ts that sends an email. That only works where a Nitro server runs. If you deploy a static build, it's gone. The Nuxt docs: "If you prerender your app with the nuxt generate or nuxt build --prerender commands, then you will not be able to use any server endpoints as no server will be included in your output folder."
With Formlane, the form posts straight from the browser to https://smallwerk.com/formlane/api/submit. Formlane stores the message and emails it to your account address, with reply-to set to the sender. No server route, no email module, no SMTP password in your config. It works the same whether you run nuxt generate, nuxt build or deploy to a serverless platform. Submissions are stored in Frankfurt.
Quick start
- Sign in to Formlane, create a form and copy its access key (
fl_…). - Paste one of the snippets below into your Nuxt app and replace
fl_YOUR_ACCESS_KEYwith your key. The key is public by design: it only allows sending to this one form, so it is fine in client code. - For the plain HTML version, set the hidden
redirectfield to a thank-you page on your own domain, for examplehttps://example.com/thanks. - In the form settings, restrict the form to your production domain, so forms on other websites can't post to it.
- Run
nuxt generate(or your usual build), deploy, send a test message, then check your inbox and the Formlane dashboard.
Option 1: a plain HTML form (works without JavaScript)
<template>
<form action="https://smallwerk.com/formlane/api/submit" method="POST">
<input type="hidden" name="access_key" value="fl_YOUR_ACCESS_KEY" />
<input type="hidden" name="subject" value="New message from my Nuxt site" />
<input type="hidden" name="redirect" value="https://example.com/thanks" />
<!-- Honeypot: hidden from people, leave it unchecked -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">Email</label>
<input id="email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit">Send</button>
</form>
</template>After a successful submission the browser is redirected to your redirect URL. Use an absolute URL (with https://). Without a redirect field, Formlane uses the default redirect from your form settings, or its own hosted thank-you page. Add app/pages/thanks.vue for your own thank-you page; with nuxt generate it becomes one more static HTML file.
The email field becomes the reply-to address of the notification, so you can answer with one click.
Option 2: a ContactForm.vue component with fetch()
Want an inline success message instead of a page change? Send the same form with fetch(). With the header Accept: application/json, Formlane answers with JSON instead of a redirect. new FormData(form) sends all fields, and an unchecked honeypot checkbox is simply not included, so real visitors never send it. Because the form keeps its action and method, it still works if JavaScript fails to load.
<script setup lang="ts">
const ENDPOINT = "https://smallwerk.com/formlane/api/submit";
// Formlane's `message` is always English, so show your own text per error code.
const MESSAGES: Record<string, string> = {
rate_limited: "Too many messages in a short time. Please try again in a few minutes.",
limit_reached: "This form can't take more messages this month. Please email us directly.",
origin_not_allowed: "This form isn't allowed on this website.",
invalid_key: "This form isn't set up correctly.",
};
const status = ref<"idle" | "sending" | "sent" | "error">("idle");
const errorText = ref("");
async function handleSubmit(event: Event) {
const form = event.currentTarget as HTMLFormElement;
status.value = "sending";
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(form),
});
const json = await res.json();
if (json.success) {
status.value = "sent";
form.reset();
} else {
status.value = "error";
errorText.value = MESSAGES[json.error] ?? "Something went wrong. Please try again.";
}
} catch {
status.value = "error";
errorText.value = "Network error. Please try again.";
}
}
</script>
<template>
<p v-if="status === 'sent'">Thanks! Your message has been sent.</p>
<form v-else :action="ENDPOINT" method="POST" @submit.prevent="handleSubmit">
<input type="hidden" name="access_key" value="fl_YOUR_ACCESS_KEY" />
<input type="hidden" name="subject" value="New message from my Nuxt site" />
<!-- Honeypot: hidden from people, leave it unchecked -->
<input type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<label for="name">Name</label>
<input id="name" type="text" name="name" required />
<label for="email">Email</label>
<input id="email" type="email" name="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit" :disabled="status === 'sending'">
{{ status === "sending" ? "Sending…" : "Send" }}
</button>
<p v-if="status === 'error'" role="alert">{{ errorText }}</p>
</form>
</template>Use it on any page with <ContactForm />.
What the response looks like
- Success (HTTP 200):
{"success": true, "message": "Form submitted successfully", "id": "…"} - Error (4xx/5xx):
{"success": false, "message": "…", "error": "…"}. Error codes includeinvalid_key,origin_not_allowed,rate_limitedandlimit_reached.
Formlane sends CORS headers, so this cross-origin fetch() works from any domain you allow in the form settings.
Keep the key in nuxt.config.ts (optional)
The access key is public, so hard-coding it is fine. If you prefer one place to change it, put it in the public runtime config:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: { formlaneKey: "fl_YOUR_ACCESS_KEY" },
},
});<script setup lang="ts">
const { public: { formlaneKey } } = useRuntimeConfig();
</script>
<template>
<input type="hidden" name="access_key" :value="formlaneKey" />
</template>What actually protects the form is the list of allowed domains in the form settings, not hiding the key.
Using it with a Nuxt UI <UForm>
Nuxt UI's UForm validates its state and then emits a submit event with the validated values in event.data. In the handler, put those values into a FormData and send it to Formlane. The honeypot stays a plain, hidden checkbox; it is only added to the request if something ticks it.
<script setup lang="ts">
import type { FormError, FormSubmitEvent } from "@nuxt/ui";
const ENDPOINT = "https://smallwerk.com/formlane/api/submit";
const state = reactive({ name: "", email: "", message: "" });
type Schema = typeof state;
const botcheck = ref(false); // honeypot, never checked by people
const sent = ref(false);
const errorText = ref("");
function validate(state: Partial<Schema>): FormError[] {
const errors: FormError[] = [];
if (!state.name) errors.push({ name: "name", message: "Required" });
if (!state.email) errors.push({ name: "email", message: "Required" });
if (!state.message) errors.push({ name: "message", message: "Required" });
return errors;
}
async function onSubmit(event: FormSubmitEvent<Schema>) {
const body = new FormData();
body.append("access_key", "fl_YOUR_ACCESS_KEY");
body.append("subject", "New message from my Nuxt site");
for (const [key, value] of Object.entries(event.data)) body.append(key, String(value ?? ""));
if (botcheck.value) body.append("botcheck", "on");
try {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { Accept: "application/json" },
body,
});
const json = await res.json();
if (json.success) {
sent.value = true;
} else {
errorText.value = json.error === "rate_limited"
? "Too many messages in a short time. Please try again in a few minutes."
: "Something went wrong. Please try again.";
}
} catch {
errorText.value = "Network error. Please try again.";
}
}
</script>
<template>
<p v-if="sent">Thanks! Your message has been sent.</p>
<UForm v-else :validate="validate" :state="state" class="space-y-4" @submit="onSubmit">
<!-- Honeypot: hidden from people, leave it unchecked -->
<input v-model="botcheck" type="checkbox" name="botcheck" style="display:none" tabindex="-1" autocomplete="off" />
<UFormField label="Name" name="name">
<UInput v-model="state.name" />
</UFormField>
<UFormField label="Email" name="email">
<UInput v-model="state.email" type="email" />
</UFormField>
<UFormField label="Message" name="message">
<UTextarea v-model="state.message" :rows="5" />
</UFormField>
<UButton type="submit">Send</UButton>
<p v-if="errorText" role="alert">{{ errorText }}</p>
</UForm>
</template>With the loadingAuto prop (on by default), UForm disables its form elements on the submit event, which helps against double submits. If you already use a schema library with UForm, keep it and pass :schema instead of :validate; the submit handler stays the same.
Server route vs. form backend (honest comparison)
| Option | Good for | Watch out for |
|---|---|---|
Your own server/api/contact.post.ts + an email API | Full control, custom logic, data in your own stack | Needs a running server (nuxt build), not nuxt generate; you build validation, spam protection, rate limiting, storage and email delivery yourself and keep email credentials on the server |
| Hosted form backend (Formlane or others) | Contact and lead forms on any Nuxt site, including fully static ones | Monthly submission quotas; features differ between services (file uploads, webhooks, autoresponders) |
Formlane fits if you want a plain HTML form, submissions stored in Frankfurt and a spam filter without captcha. If you need file uploads, autoresponders, webhooks or a captcha today, pick a service that already has them: Formlane doesn't yet.
Spam without a captcha
- Honeypot: the hidden
botcheckcheckbox. People never see it; bots that tick it get a normal success response and nothing is stored. - Rate limit: at most 10 submissions per 10 minutes per visitor and form.
- Content rules: simple rules such as link flooding. Flagged messages go to a separate Spam tab in your dashboard and are not emailed.
More in the guide Contact form spam without a captcha.
Limits
64 KB per submission, 50 fields, 10,000 characters per field, 10 submissions per 10 minutes per visitor and form. File uploads are not supported yet.
Using a coding agent?
The Formlane prompt for AI coding agents gives Claude Code, Cursor or another agent the endpoint, the snippets and the honeypot in one copy-paste block. To read your submissions from code or from an agent, use the read-only REST API and MCP server, available on every plan, including Free. Prefer to click a form together? The contact form generator writes the HTML for you.
Pricing
Free: 250 submissions a month and up to 5 forms. Starter: $8/month for 2,500 submissions. Pro: $19/month for 25,000 submissions. Prices are in USD, plus applicable taxes. Details on the Formlane page.
Related guides
FAQ
Does it work with nuxt generate and static hosting?
Yes. A prerendered Nuxt build has no server endpoints, but this form doesn't need one: it posts from the visitor's browser to Formlane.
Do I need a server/api route or an email module?
No. The form's action (or the fetch() call) points at https://smallwerk.com/formlane/api/submit. Formlane receives the POST, stores the message and emails it to you.
Does it work with Nuxt UI forms?
Yes. Handle UForm's submit event, copy event.data into a FormData together with your access key and send it with fetch(), as in the Nuxt UI example above.
Is the access key safe in the browser?
Yes. The Formlane access key is public by design and only allows sending submissions to that one form. Restrict the form to your domain in its settings, so forms on other websites can't use it.
How do I block spam without a captcha?
Keep the hidden botcheck checkbox. Together with the rate limit and the content rules, that handles typical form spam without a captcha. Flagged messages land in a Spam tab and are not emailed.
Can visitors upload files?
Not yet. Formlane accepts text fields only for now.
What happens when I reach the monthly limit?
New submissions are rejected with the error limit_reached until the next calendar month or an upgrade. Submissions you already have are kept.
Where are submissions stored?
Submissions are stored in Frankfurt. The privacy policy lists all providers.
Get your access key
Free for 250 submissions a month. Submissions stored in Frankfurt.
Get your access key