Comparison

Vue contact form component

Two single-file components for Vue 3 with <script setup> and TypeScript. They also work in Nuxt as they are: put them in the components folder and Nuxt imports them for you.

Short answer: Save ContactForm.vue, replace fk_your_access_key with your Formgong key and render <ContactForm />. In Nuxt 4 the file goes in app/components/.

Contact form component

Two refs hold the state: status disables the button while sending, and notice holds the text the visitor reads. The success text appears only after result.success === true.

ContactForm.vue
<script setup lang="ts">
import { ref } from "vue";

const ACCESS_KEY = "fk_your_access_key";
const status = ref<"idle" | "sending" | "sent" | "error">("idle");
const notice = ref("");

async function handleSubmit(event: SubmitEvent) {
  const form = event.currentTarget as HTMLFormElement;
  const data = new FormData(form);
  data.set("access_key", ACCESS_KEY);
  status.value = "sending";
  notice.value = "";
  try {
    const response = await fetch("https://formgong.com/submit", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: data,
    });
    const result: { success: boolean; message?: string } = await response.json();
    if (result.success === true) {
      form.reset();
      status.value = "sent";
      notice.value = "Thanks! Your message has been sent.";
    } else {
      status.value = "error";
      notice.value = result.message || "Not sent. Please try again.";
    }
  } catch {
    status.value = "error";
    notice.value = "Not sent: check your connection and try again.";
  }
}
</script>

<template>
  <form class="contact-form" @submit.prevent="handleSubmit">
    <label>Name <input type="text" name="name" autocomplete="name" required /></label>
    <label>Email <input type="email" name="email" autocomplete="email" required /></label>
    <label>Message <textarea name="message" rows="5" required></textarea></label>
    <!-- Honeypot: people never see it, simple bots fill it in. -->
    <div class="hp" aria-hidden="true"><input type="text" name="botcheck" tabindex="-1" autocomplete="off" /></div>
    <button type="submit" :disabled="status === 'sending'">
      {{ status === "sending" ? "Sending…" : "Send message" }}
    </button>
    <p :role="status === 'error' ? 'alert' : 'status'">{{ notice }}</p>
  </form>
</template>

<style scoped>
.contact-form { position: relative; display: grid; gap: 1rem; max-width: 32rem; }
label { display: grid; gap: 0.35rem; font-weight: 600; }
input, textarea { font: inherit; padding: 0.6rem 0.75rem; border: 1px solid #9aa5b1; border-radius: 8px; }
button { justify-self: start; padding: 0.7rem 1.2rem; border: 0; border-radius: 8px; background: #2f5bea; color: #fff; font: inherit; font-weight: 600; }
.hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
</style>

Newsletter signup component

One email field plus a hidden _subject.

NewsletterSignup.vue
<script setup lang="ts">
import { ref } from "vue";

const ACCESS_KEY = "fk_your_access_key";
const status = ref<"idle" | "sending" | "sent" | "error">("idle");
const notice = ref("");

async function handleSubmit(event: SubmitEvent) {
  const form = event.currentTarget as HTMLFormElement;
  const data = new FormData(form);
  data.set("access_key", ACCESS_KEY);
  status.value = "sending";
  notice.value = "";
  try {
    const response = await fetch("https://formgong.com/submit", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: data,
    });
    const result: { success: boolean; message?: string } = await response.json();
    if (result.success === true) {
      form.reset();
      status.value = "sent";
      notice.value = "You're subscribed. Thanks!";
    } else {
      status.value = "error";
      notice.value = result.message || "Not sent. Please try again.";
    }
  } catch {
    status.value = "error";
    notice.value = "Not sent: check your connection and try again.";
  }
}
</script>

<template>
  <form class="newsletter" @submit.prevent="handleSubmit">
    <input type="hidden" name="_subject" value="New newsletter signup" />
    <label for="newsletter-email">Get new posts by email</label>
    <div class="row">
      <input id="newsletter-email" type="email" name="email" autocomplete="email" placeholder="you@example.com" required />
      <button type="submit" :disabled="status === 'sending'">Subscribe</button>
    </div>
    <div class="hp" aria-hidden="true"><input type="text" name="botcheck" tabindex="-1" autocomplete="off" /></div>
    <p :role="status === 'error' ? 'alert' : 'status'">{{ notice }}</p>
  </form>
</template>

<style scoped>
.newsletter { position: relative; display: grid; gap: 0.5rem; max-width: 32rem; }
.row { display: flex; gap: 0.5rem; flex-wrap: wrap; }
input { flex: 1 1 14rem; font: inherit; padding: 0.6rem 0.75rem; border: 1px solid #9aa5b1; border-radius: 8px; }
button { padding: 0.6rem 1.1rem; border: 0; border-radius: 8px; background: #2f5bea; color: #fff; font: inherit; font-weight: 600; }
.hp { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
</style>

Using it in Nuxt

Nuxt auto-imports components, so the page only needs the tag. The form sends from the browser, so it works with server rendering, static generation (nuxt generate) and any host. No server route is needed.

app/pages/contact.vue
<template>
  <main>
    <h1>Contact us</h1>
    <!-- Nuxt 4 auto-imports app/components/ContactForm.vue (components/ on Nuxt 3) -->
    <ContactForm />
  </main>
</template>

How we tested this code

We pasted each snippet unchanged into a fresh project (Vue 3.5 with Vite 8, and Nuxt 4.6), filled in a real access key, and submitted it in Chromium three times against a local copy of Formgong. With the right key the form showed its success text and exactly one submission was stored, not marked as spam. With a key Formgong does not know, the form showed Formgong's own error, "That access key does not exist.", and nothing was stored. With the request cut off, the form said it was not sent. None of the snippets says "sent" before Formgong answers success: true.

vue-tsc and nuxt typecheck found no type errors.

Questions

Is it safe to put the access key in front-end code?

Yes. The key only lets someone send a submission to your form, the same thing your page already does. It cannot read submissions or change settings. If you want it out of the page source anyway, add your domain under allowed domains in the form settings, or post through your own server.

Does it work with the Options API or Vue 2?

We tested it with Vue 3 only. For the Options API, move the two refs into data() and handleSubmit into methods; the fetch call stays the same.

Should I use a Nuxt server route instead?

Only if you want the key out of the page. Posting from the browser keeps the visitor's IP address visible to Formgong's spam checks and needs no server code.

What is the hidden botcheck field?

A honeypot. It sits off screen, so people never fill it, while simple bots fill every field they find. Formgong marks a submission with it filled in as spam, so it does not reach your email. Keep the name botcheck.

Sources

Checked on 16.10.2026 against these public pages: Vue: Form input bindings, Nuxt: components directory.

← Formgong