Nuxt
Add a working contact form to a Nuxt site, including statically generated ones, with $fetch and no server route.
Nuxt can run server routes, but a contact form does not need one. The component posts straight to SilentForms with $fetch, so the same code works under nuxt dev, on a Node or edge deployment, and on a fully static site produced by nuxt generate. The examples use Nuxt 4, where app code lives in app/; on Nuxt 3, drop the app/ prefix from the paths.
What you'll build#
A ContactForm component that:
- reads the access key from
runtimeConfig.public, - submits with
$fetchand shows sending, success and error states, - is rendered into the HTML on the server or at generate time, so it still posts without JavaScript.
1. Add the key to runtime config#
Get a key on the access key page, then declare it in nuxt.config.ts:
export default defineNuxtConfig({
runtimeConfig: {
public: {
silentformsKey: '', // set by NUXT_PUBLIC_SILENTFORMS_KEY
},
},
});and set it in .env:
NUXT_PUBLIC_SILENTFORMS_KEY=YOUR_ACCESS_KEY_HERENuxt maps NUXT_PUBLIC_* variables onto runtimeConfig.public by name. Only keys under public reach the browser, which is what you want here: the access key only routes submissions to your inbox. Never put the key under the private part of runtimeConfig and then try to read it in the component; it will be undefined on the client.
On a server deployment the variable can be changed at runtime without rebuilding. With nuxt generate there is no runtime: the value is read once while the pages are generated, so the variable must be set in the build environment of your static host.
2. The component#
Create app/components/ContactForm.vue:
<script setup lang="ts">
const { public: { silentformsKey } } = useRuntimeConfig();
const state = reactive({
status: 'idle' as 'idle' | 'sending' | 'sent' | 'error',
message: '',
});
async function onSubmit(event: SubmitEvent) {
const form = event.target as HTMLFormElement;
state.status = 'sending';
state.message = '';
try {
await $fetch('https://silentforms.com/api/submit', {
method: 'POST',
headers: { Accept: 'application/json' },
body: Object.fromEntries(new FormData(form)),
});
form.reset();
state.status = 'sent';
state.message = 'Thanks! Your message has been sent.';
} catch (error: any) {
state.status = 'error';
state.message = error?.data?.message || 'Something went wrong. Please try again.';
}
}
</script>
<template>
<form action="https://silentforms.com/api/submit" method="POST" @submit.prevent="onSubmit">
<input type="hidden" name="accessKey" :value="silentformsKey" />
<label for="cf-name">Name</label>
<input id="cf-name" name="name" type="text" autocomplete="name" required />
<label for="cf-email">Email</label>
<input id="cf-email" name="email" type="email" autocomplete="email" required />
<label for="cf-message">Message</label>
<textarea id="cf-message" name="message" rows="5" required></textarea>
<input type="checkbox" name="botcheck" style="display: none" tabindex="-1" autocomplete="off" />
<button type="submit" :disabled="state.status === 'sending'">
{{ state.status === 'sending' ? 'Sending…' : 'Send message' }}
</button>
<p :role="state.status === 'error' ? 'alert' : 'status'">{{ state.message }}</p>
</form>
</template>Components in app/components/ are auto-imported, so a page only needs the tag. Create app/pages/contact.vue:
<script setup lang="ts">
useSeoMeta({ title: 'Contact' });
</script>
<template>
<main>
<h1>Contact us</h1>
<ContactForm />
</main>
</template>$fetch, not useFetch#
useFetch and useAsyncData are for loading data while a page renders: they run during setup, dedupe by key, and transfer the result from server to client in the payload. A form submission is the opposite, a one-off request triggered by the visitor, so call $fetch directly inside the handler. Calling useFetch in a submit handler is a common mistake; it is meant for component setup, and you would be fighting its caching and reactive re-fetching.
When $fetch gets a non-2xx response it throws a FetchError. The parsed JSON body is on error.data, so error.data.message is the endpoint's visitor-friendly message, such as "Email address is invalid.". Passing a plain object as body makes $fetch send it as JSON with the right content type.
Works without JavaScript#
Because the form has a real action, method and name on every field, the HTML that Nuxt renders on the server (or writes at generate time) is a working form on its own. If the visitor's JavaScript has not loaded yet or failed, the browser posts the form and SilentForms redirects to its thank-you page. Once Nuxt hydrates, @submit.prevent takes over and the visitor stays on the page. To send no-JS visitors to your own page, add a hidden redirect field; see Redirect after submit.
Static hosting#
Run nuxt generate and deploy .output/public to any static host: GitHub Pages, Cloudflare Pages, Netlify, an S3 bucket. Nothing in this setup needs Nitro at runtime. If you deploy with ssr: false (a pure single-page app) the form still works, but the no-JS fallback does not, since the HTML is empty until the app loads.
Testing#
- Run
nuxt dev, open/contactand send yourself a message. - Disable JavaScript in your browser's dev tools and submit again: you should land on the SilentForms thank-you page and get a second email.
- Run
nuxt generate, then serve.output/publicwith any static server (for examplenpx serve .output/public) and repeat the test. If you see "Access key is required.", the variable was not set when the pages were generated.
Next steps#
- The same component in plain Vue: Vue.
- Send submissions to a spreadsheet or chat tool: Webhooks.
- Lock the key to your site: Restrict to your domain.