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.
recommended setup
Add this HTML to your site, or use the form builder. Replace the form link with yours. The hosted script injects the hidden check, handles sending and automatic verification, and preserves the draft on errors. You control the HTML and CSS. Free forms display a visible “Processed by mailcontact.app” link; Pro and Plus do not. Feedback follows the browser’s language; set data-mailcontact-language="fr" on the form to force French.
<form action="https://mailcontact.app/api/forms/your-form-id" method="POST" data-mailcontact-form>
<fieldset>
<legend>Contact us</legend>
<label>
Name (optional)
<input name="name" type="text" autocomplete="name" maxlength="200" />
</label>
<label>
Email
<input name="email" type="email" autocomplete="email" maxlength="320" required />
</label>
<label>
Message
<textarea name="message" rows="5" maxlength="5000" required></textarea>
</label>
<noscript>
<div aria-hidden="true" style="position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden;">
<label>Website
<input name="company_website" type="text" tabindex="-1" autocomplete="off" />
</label>
</div>
</noscript>
<button type="submit">Send message</button>
</fieldset>
<p data-contact-status role="status" aria-live="polite" aria-atomic="true"></p>
</form>
<script defer src="https://mailcontact.app/forms/v1.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>For a component with its own submit handler, load the same script and use MailContact.prepare(form) and MailContact.send() instead of the automatic marker. See the custom interface reference for loading, feedback and Free attribution.
the snippet
This example posts directly to the API. Keep the required company_website field present and empty. Native posts open JSON on success or a verification page when needed. Custom scripts without verification support send suspicious messages to Spam for review; strong spam is also held there without notifications or quota usage. A success response confirms reception, including review, or a silent discard of a filled check. See spam protection for the full behavior.
<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="[email protected]" required />
<textarea name="message" placeholder="What's on your mind?" required />
<input name="company_website" type="text" tabindex="-1" autocomplete="off"
style="position:absolute;left:-10000px" aria-hidden="true" />
<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 company_website check must be present and empty. Filling it silently discards the submission, but a capable bot can skip it. MailContact also applies server-side limits, content and campaign checks. The hosted script handles automatic verification when needed; strong spam is held for review without notifications or quota usage.
related
Forms and a real email address, together, with notifications where you live. Start with a free account.
Create your free formNo credit card needed. Free includes one project and 50 form messages per month.