Nuxt contact form in four minutes (no API route, no Nitro handler)
April 5, 2026 · 6 min read
Nuxt 3 is a joy to work with. Nitro, the server engine underneath it, makes spinning up an API route so easy that developers keep doing it for things that don't need an API route. A contact form is the canonical example: the path of least resistance is to create a server/api/contact.post.ts file, wire it up to an email library, and call it done.
Here's the thing. That server handler will work. It will also:
- Force you to ship a server runtime (goodbye fully static deploy)
- Require you to own email delivery, spam filtering, webhook retries, and SMTP
- Cost you ~100 lines of TypeScript you'll need to maintain
- Add a class of bugs that a
<form>tag pointing at a hosted endpoint simply doesn't have
Let me show you the other way.
The setup
Your form lives in pages/contact.vue. It posts directly to a hosted form backend. No Nuxt server involved. Your app can deploy to Cloudflare Pages, Vercel (as static), GitHub Pages, or an S3 bucket — the form works everywhere because the browser handles the submission natively.
Step 1 — The endpoint
Sign up for a hosted form backend. I'll use FormTo for this walkthrough. Create a form in the dashboard, grab the URL:
https://api.formto.dev/f/your-form-slug
Put it in your .env file with the NUXT_PUBLIC_ prefix so it's exposed to the client (form endpoints are public by design — the browser has to see the URL to POST to it):
NUXT_PUBLIC_CONTACT_ENDPOINT=https://api.formto.dev/f/your-form-slug
And make it available through runtimeConfig in nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
public: {
contactEndpoint: '',
},
},
})
Nuxt automatically maps NUXT_PUBLIC_CONTACT_ENDPOINT → runtimeConfig.public.contactEndpoint at build time. Clean.
Step 2 — The contact page
Create pages/contact.vue:
<script setup lang="ts">
const config = useRuntimeConfig()
const endpoint = config.public.contactEndpoint
useHead({
title: 'Contact us',
})
</script>
<template>
<main class="mx-auto max-w-lg px-6 py-16">
<h1 class="text-4xl font-bold mb-4">Say hi</h1>
<p class="text-gray-600 mb-8">
Real replies from real humans. Usually within a day.
</p>
<form
method="POST"
:action="endpoint"
class="space-y-4"
>
<label class="block">
<span class="text-sm font-medium">Name</span>
<input
type="text"
name="name"
required
class="mt-1 w-full border rounded-md px-3 py-2"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Email</span>
<input
type="email"
name="email"
required
class="mt-1 w-full border rounded-md px-3 py-2"
/>
</label>
<label class="block">
<span class="text-sm font-medium">Message</span>
<textarea
name="message"
rows="5"
required
class="mt-1 w-full border rounded-md px-3 py-2"
/>
</label>
<!-- honeypot -->
<input
type="text"
name="website"
tabindex="-1"
autocomplete="off"
class="hidden"
aria-hidden="true"
/>
<input type="hidden" name="_redirect" value="https://yoursite.com/thanks" />
<button
type="submit"
class="px-5 py-2 bg-black text-white rounded-md hover:bg-gray-800"
>
Send message
</button>
</form>
</main>
</template>
That's the whole file. Zero state, zero event handlers, zero API calls, zero Nuxt-specific server hooks. The form is just HTML wrapped in a <template> block, which is Vue's entire point.
Step 3 — The thank-you page
Create pages/thanks.vue:
<script setup lang="ts">
useHead({ title: 'Thanks' })
</script>
<template>
<main class="mx-auto max-w-lg px-6 py-24 text-center">
<h1 class="text-4xl font-bold mb-2">Got it.</h1>
<p class="text-gray-600 mb-6">We'll reply within a day or two.</p>
<NuxtLink to="/" class="text-blue-600 underline">← Back home</NuxtLink>
</main>
</template>
Done. Build, deploy, submit a test from your phone, watch the notification land in Slack or your inbox.
Going fully static
One of the real wins of this approach is that your entire Nuxt site can be pre-rendered to static HTML. In nuxt.config.ts:
export default defineNuxtConfig({
ssr: true,
nitro: {
preset: 'static',
},
runtimeConfig: {
public: {
contactEndpoint: '',
},
},
})
Run nuxt generate instead of nuxt build. The output in .output/public/ is a pile of static HTML, CSS, and JS. Deploy it anywhere — Cloudflare Pages, Netlify, GitHub Pages, an S3 bucket. The contact form will keep working because the form never needed a server in the first place.
If you already have a server-rendered Nuxt site and don't want to go static, that's fine too. The form setup works identically in SSR mode. You just don't need SSR for a contact form, and if it's the only thing keeping your site non-static, worth reconsidering.
What about Nuxt's server/api handler approach?
Let me acknowledge the alternative fairly. Nuxt's server API routes are objectively elegant. A single server/api/contact.post.ts file can look like:
export default defineEventHandler(async (event) => {
const body = await readBody(event)
// validate, dispatch email, return response
return { ok: true }
})
And you can hit it from the client with $fetch('/api/contact', { method: 'POST', body }). Beautiful.
The honest cost of that approach:
- You have to own the email library (nodemailer, or @vercel/og, or resend, or mailgun)
- You have to own API keys and rotate them
- You have to own rate limiting
- You have to own spam filtering (or bolt on Akismet / Cloudflare Turnstile)
- You have to own retries when downstream services blip
- You have to own the "submissions dashboard" (or there isn't one)
- You need a server runtime, which kills static deployment
For a marketing site's contact form, none of that is worth the elegance. For a complex application where the form needs to integrate deeply with your own business logic, it absolutely is.
Pick based on the specific form. Most contact forms aren't the second kind.
The Nuxt-specific thing I like about this setup
Because the form is a plain <form> with a native action attribute, Vue doesn't have to hydrate any state around it. The form renders as static HTML, works before Vue's runtime loads, and submits natively even if your JavaScript bundle hasn't finished downloading yet.
This matters more than it sounds like it should. A user on a slow connection, or a user with a browser extension that blocks analytics and breaks Vue hydration, still gets a working contact form. The native <form> element is the most bulletproof piece of web infrastructure you can reach for.
I wrote a longer manifesto about why plain HTML forms deserve more respect in the age of meta-frameworks. The Nuxt version of the argument is: if the framework gives you <template>, you don't need to re-invent the form.
Create a free form, paste the URL into your .env, run nuxt generate, deploy. Your Nuxt site has a contact form that survives being fully static, works without JavaScript, and sends notifications to wherever you like.
Other frameworks in the same series: Astro, Next.js, SvelteKit, Gatsby, Hugo, Eleventy, Webflow, Framer, and GitHub Pages.
More posts