Static output stays on the table
A Nitro route is the one thing that stops nuxt generate from being enough. Keep the form external and you keep the option to deploy the whole site as files, whenever you want it.
integrations · framework
Nuxt ships a server. Drop a file in server/api/, and Nitro gives you a real endpoint with no extra setup, which makes the obvious contact-form answer look easy. Then you run nuxt generate to prerender the site onto a CDN, and the obvious answer disappears: a fully static Nuxt build has no Nitro runtime, so server/api/contact.post.ts is compiled and then never deployed anywhere that can run it.
This is the trap that catches people who picked Nuxt precisely because it can go static. Point the form at a mailcontact endpoint and the contact page stops caring: it works identically under nuxt build with a Node server, under nuxt generate on a CDN, and on the preset your host swaps you onto next quarter.
the snippet
Point the form at your project's mailcontact endpoint. Submissions land in your project inbox. No server, no API route, no email service to wire up.
<script setup>
// No server/api/contact.post.ts. Nothing here needs Nitro at runtime,
// so `nuxt generate` keeps this page fully working.
const sent = ref(false);
async function submit(event) {
await $fetch("https://mailcontact.app/api/forms/your-form-id", {
method: "POST",
body: new FormData(event.target),
});
sent.value = true;
}
</script>
<template>
<p v-if="sent">Thanks. We'll be in touch.</p>
<form v-else @submit.prevent="submit">
<input name="name" placeholder="Your name" required />
<input name="email" type="email" placeholder="you@email.com" required />
<textarea name="message" placeholder="What's on your mind?" required />
<input name="_gotcha" tabindex="-1" autocomplete="off" hidden />
<button>Send</button>
</form>
</template>how it works
One inbox per project. The form endpoint and the project email address are live before you add the page.
Paste it into pages/contact.vue and swap the endpoint id. There is no server/ file to add, no runtimeConfig entry, and no private key to keep out of the client bundle.
Add a Discord, Slack, email, or webhook channel, and submissions reach you the moment they arrive.
why mailcontact
A Nitro route is the one thing that stops nuxt generate from being enough. Keep the form external and you keep the option to deploy the whole site as files, whenever you want it.
The DIY version needs an API key for a mail provider, which means a secret, a place to store it, and a per-host config to get wrong. There is no key here to leak or rotate.
Nitro presets differ per host, and a server route that works on one can behave differently on another. Markup pointing at an external URL behaves the same everywhere, because it never touches the preset.
faq
Yes, if you deploy a server. server/api/contact.post.ts works well under nuxt build with a Node or serverless preset. It stops working under nuxt generate, because a prerendered site has no Nitro runtime to receive the POST. If static output matters to you, keep the form off the server graph.
Yes. The example only uses ref, $fetch and @submit.prevent, all of which are stable across both. You can also swap $fetch for the native fetch: the payload is a FormData either way, so nothing here depends on Nuxt's fetch layer.
No. useFetch is built for data you need during render, and it dedupes and caches, which is wrong for a one-shot POST triggered by a click. Call $fetch (or fetch) directly inside the submit handler.
The hidden _gotcha field in the example is a honeypot: bots fill in every input they find, humans never see it, and a submission with it filled is dropped silently. It costs one line of markup and no user friction, unlike a challenge widget.
related
Forms and a real email address, together, with notifications where you live. Launching soon. Be the first to know.