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 $fetch and 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_HERE

Nuxt 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#

  1. Run nuxt dev, open /contact and send yourself a message.
  2. 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.
  3. Run nuxt generate, then serve .output/public with any static server (for example npx 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#