splitforms.com
All articles/ TUTORIALS10 MIN READPublished May 10, 2026

How to Add a Working Contact Form to Nuxt 3 in 2026

Add a working contact form to Nuxt 3 in 2026 — composables, server route alternative, validation, and email delivery without writing an API backend.

✶ Written by
splitforms.com / blog

Founder of splitforms — the form backend API for developers. Writes about form UX, anti-spam, and shipping web apps without backend code.

Nuxt 3 has two ways to handle a form — pick the simpler one

Nuxt 3 gives you two paths to ship a working contact form. Most tutorials show you the wrong one first.

Path A — Nitro server route. You create server/api/contact.ts, write a handler with defineEventHandler, parse the body with readBody, call Nodemailer or Resend or SES, handle errors, set up env vars for SMTP credentials, configure your deploy target to keep server functions warm, debug cold starts. You own every layer.

Path B — Hosted form backend. You write a <ContactForm.vue> component that calls $fetch directly from the browser to a hosted endpoint. The endpoint sends the email. You write zero backend code, deploy as a fully static Nuxt build if you want, and the server-side surface area is zero.

Path A makes sense when you need server-side logic — looking up a customer in your DB, calling a CRM, conditional routing. For a plain "send me an email when someone fills this out" form, Path A is over-engineering. You're writing and maintaining infrastructure to do what an HTTP POST can do directly.

This guide covers Path B with splitforms as the backend. If you've already shipped a Nitro route and want to keep it, the Vue component code in step 3 still works — just point $fetch at your local /api/contact instead.

Why a hosted backend is cleaner than server/api/contact.ts

I've maintained both patterns. Here's what you actually trade.

When you own server/api/contact.ts:

  • SMTP credentials live in your env. Rotated every 90 days for Gmail App Passwords, never for Resend. Either way it's your problem.
  • Cold starts. On Vercel and Netlify, an idle server function takes 800ms–2s to wake. Users mash submit twice.
  • Error visibility. When SendGrid returns 451 because your domain has a soft SPF fail, you find out from a confused user, not a dashboard.
  • Spam. You'll bolt on a honeypot, then reCAPTCHA when the honeypot stops working, then a rate limit when reCAPTCHA gets bypassed.
  • Storage. Where do submissions go? If you only email them, one bounced email and the data is gone. Now you're writing to Postgres or KV.

With a hosted backend (splitforms or similar), the dashboard, retries, AI spam classification, webhook fanout, and email delivery come for free. The only thing you write is a Vue component and an env var. That's the deal. For an indie project or a marketing site, owning the backend is a poor use of your hours.

Cost-wise: splitforms is 1,000 submissions/month free, $5/mo for 5,000, $59 for 4 years. Running your own server route is "free" only if you don't count your time.

Step 1: Get a splitforms access key (1 minute)

  1. Go to splitforms.com/login
  2. Enter your email, paste the 6-digit code
  3. Copy the access key from the dashboard — it looks like pk_live_xxxxxxxx

No credit card, no plan selection. Free tier is the default. The access key is what authenticates submissions — keep one per form if you want separate dashboards and stats per page, or use one key globally and tag with a hidden form_id field.

Step 2: Build the ContactForm.vue SFC

Create components/ContactForm.vue. Here's a working SFC with reactive refs and $fetch — paste this verbatim and swap the access key:

<!-- components/ContactForm.vue -->
<script setup lang="ts">
const name = ref('')
const email = ref('')
const message = ref('')
const botcheck = ref('')
const submitting = ref(false)
const submitted = ref(false)
const error = ref<string | null>(null)

async function onSubmit() {
  error.value = null
  submitting.value = true
  try {
    await $fetch('https://splitforms.com/api/submit', {
      method: 'POST',
      body: {
        access_key: 'YOUR_ACCESS_KEY',
        name: name.value,
        email: email.value,
        message: message.value,
        botcheck: botcheck.value,
      },
    })
    submitted.value = true
  } catch (e: any) {
    error.value = e?.data?.message || 'Something went wrong. Try again.'
  } finally {
    submitting.value = false
  }
}
</script>

<template>
  <div v-if="submitted" class="thanks">
    <h3>Thanks — we got your message.</h3>
    <p>We'll reply within 24 hours.</p>
  </div>

  <form v-else @submit.prevent="onSubmit" novalidate>
    <label>
      Name
      <input v-model="name" type="text" required />
    </label>

    <label>
      Email
      <input v-model="email" type="email" required />
    </label>

    <label>
      Message
      <textarea v-model="message" rows="5" required />
    </label>

    <!-- Honeypot: real users won't fill this, bots will -->
    <input
      v-model="botcheck"
      type="checkbox"
      name="botcheck"
      tabindex="-1"
      style="display:none"
      aria-hidden="true"
    />

    <p v-if="error" class="error">{{ error }}</p>

    <button type="submit" :disabled="submitting">
      {{ submitting ? 'Sending…' : 'Send message' }}
    </button>
  </form>
</template>

That's the whole component. Drop it into any page with <ContactForm /> and it works. No API route, no SMTP setup, no Nodemailer install. Submissions land in your email and in the splitforms dashboard.

useFetch vs $fetch — which one belongs in a submit handler

This trips up most Nuxt newcomers. Both are Nuxt's built-in HTTP tools, but they exist for different jobs.

useFetch is a composable. It runs on the server during SSR, caches the result in the Nuxt payload, dedupes parallel calls, and re-runs reactively when its dependencies change. It's designed for data loading at render time — "fetch the blog post when the page renders." You call it at the top of <script setup>, not inside an event handler.

$fetch is a plain function (ofetch under the hood). It runs when you call it, returns a Promise, and has no SSR or reactivity behavior. It's the right tool for imperative actions — submitting a form, posting an analytics event, calling an API after a click.

If you try useFetch inside onSubmit, you'll get a warning about "composables must be called in setup" and the request will fire on render, not on click. Use $fetch for the submit. Use useFetch for pre-populating fields from your CMS. Different tools, different jobs.

For more on the Nuxt-flavored Vue patterns, the official Nuxt 3 docs cover the data fetching distinction well — but if you remember "useFetch for render, $fetch for handlers," you'll write idiomatic code without thinking about it.

Step 3: Move the access key to runtime config (optional)

Hardcoding the key works — it's public anyway. But if you want env-var driven config (different keys per environment, easier rotation), Nuxt's runtimeConfig is the way:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      splitformsKey: process.env.NUXT_PUBLIC_SPLITFORMS_KEY || '',
    },
  },
})

Then in .env:

NUXT_PUBLIC_SPLITFORMS_KEY=pk_live_xxxxxxxx

And in the SFC, replace the hardcoded key:

<script setup lang="ts">
const config = useRuntimeConfig()
// ...
await $fetch('https://splitforms.com/api/submit', {
  method: 'POST',
  body: {
    access_key: config.public.splitformsKey,
    name: name.value,
    email: email.value,
    message: message.value,
    botcheck: botcheck.value,
  },
})
</script>

Note the public namespace — anything in runtimeConfig.public ships to the browser bundle. That's fine for the splitforms key because it's a public submission token, scoped by Allowed Domains in the dashboard. Don't put real secrets (Stripe secret keys, DB passwords) in runtimeConfig.public; those go in the root runtimeConfig and stay server-only.

Validation: Vee-Validate or vanilla refs?

The component above uses HTML5 required attributes plus the server-side validation that splitforms does on every submission. For most contact forms that's enough. If you want richer client-side rules — "email must be a work address," "message between 20 and 2000 chars," cross-field rules — you have two clean options.

Option A — Vanilla refs with computed validators

const emailValid = computed(() =>
  /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value)
)
const messageValid = computed(() => message.value.length >= 20)
const formValid = computed(() => emailValid.value && messageValid.value)

// In template:
// <button :disabled="!formValid || submitting">Send</button>

Zero dependencies, full control, reads top-to-bottom. For forms under 6 fields this is what I reach for.

Option B — Vee-Validate with a yup schema

// npm i vee-validate yup
import { useForm, useField } from 'vee-validate'
import * as yup from 'yup'

const schema = yup.object({
  name: yup.string().required().min(2),
  email: yup.string().required().email(),
  message: yup.string().required().min(20).max(2000),
})

const { handleSubmit, errors } = useForm({ validationSchema: schema })
const { value: name } = useField<string>('name')
const { value: email } = useField<string>('email')
const { value: message } = useField<string>('message')

const onSubmit = handleSubmit(async (values) => {
  await $fetch('https://splitforms.com/api/submit', {
    method: 'POST',
    body: { access_key: 'YOUR_ACCESS_KEY', ...values, botcheck: '' },
  })
})

Vee-Validate shines when you have 8+ fields, async validators (checking a username is taken), or a multi-step wizard. For a 3-field contact form, the schema overhead isn't worth it.

Spam protection: honeypot first, AI classification second

The hidden botcheck input in the component is a honeypot. Naive bots fill every field; if botcheck has a value, splitforms drops the submission silently before it reaches your inbox.

The honeypot catches roughly 80% of automated spam. The remaining 20% is sophisticated enough to skip hidden fields — those get filtered by splitforms' AI spam classifier, which scores each submission on writing pattern, link density, language profile, and IP reputation. You don't configure it; it just runs.

Skip CAPTCHA unless you're getting hammered. The deeper read on why is in honeypot vs reCAPTCHA — the short version is CAPTCHA hurts conversion 8–15% and the AI filter does the same job invisibly.

If you do need a visible challenge for high-value forms (RFQ, demo request), Cloudflare Turnstile is the modern pick — it's free, GDPR-safe, and integrates cleanly with splitforms via a webhook step.

Deploy to Vercel, Netlify, or Cloudflare Pages

Because there's no Nitro server route, you can deploy as a fully static Nuxt build:

# nuxt.config.ts
export default defineNuxtConfig({
  ssr: false,        // or true if you want SSG / SSR for SEO
  nitro: {
    preset: 'static' // or 'vercel', 'netlify', 'cloudflare-pages'
  }
})

Then:

npm run generate    # static build
# or
npm run build       # SSR / hybrid build

Vercel

Push to GitHub, import in Vercel dashboard, set NUXT_PUBLIC_SPLITFORMS_KEY in Project Settings → Environment Variables. Vercel auto-detects Nuxt and picks the right preset. Done.

Netlify

Same flow. netlify.toml isn't required; the Nuxt build picks up the Netlify preset automatically when deployed there. Set the env var in Site settings → Environment variables.

Cloudflare Pages

Connect the repo, set build command to npm run build and output to dist/ for static, or use the cloudflare-pages preset for SSR with Workers. Cloudflare's 100MB request limit is more than enough for text submissions; if you allow file uploads, validate size client-side first.

If you're also shipping React-flavored forms in another project, the patterns translate — see the Next.js form backend guide or the React form backend drop-in.

Troubleshooting: the four things that actually break

Hydration mismatch warnings

Symptom: console warns "Hydration node mismatch" the first time the form renders. Cause: server-rendered HTML differs from the first client render. For forms this is usually browser autofill firing before Vue hydrates, or a value seeded from Date.now(). Fix: wrap any client-only logic in <ClientOnly>, or pre-seed values to the same default on both sides. Don't suppress the warning globally — it's a real signal.

CSRF token confusion

Symptom: you're reading a Laravel or Rails tutorial and panicking about CSRF. Cause: muscle memory from session-authed apps. splitforms uses an access key in the body, not a session cookie — there's no CSRF vector. Skip the token entirely. If you also have a Nitro route on the side that does use sessions, then yes, add nuxt-csurf or similar. Otherwise no.

Nitro server routes shadowing the fetch

Symptom: you created server/api/submit.ts earlier for testing and now $fetch('/api/submit') hits your local route instead of splitforms. Cause: you used a relative URL. Fix: always use the absolute https://splitforms.com/api/submit URL in the body of the form component. Reserve /api/* paths in Nitro for your own logic.

401 Unauthorized on submission

Symptom: $fetch throws with a 401. Cause: wrong or whitespace-padded access key, or your domain isn't in the splitforms Allowed Domains list. Fix: re-copy the key from the dashboard, check Allowed Domains includes both your production domain and localhost for dev. More diagnostic patterns in contact form not working.

What to do next

FAQ

Do I need a Nitro server route to send the email?

No. That's the whole point of using a hosted form backend. Your Nuxt app posts the form directly to splitforms' /api/submit endpoint and splitforms handles the email. Writing server/api/contact.ts means you maintain SMTP credentials, Nitro deploy config, error handling, and rate limits yourself. With splitforms you ship a Vue component and you're done. Use a Nitro route only if you need server-side enrichment before delivery — like looking up a CRM record or validating against your DB.

Should I use useFetch or $fetch for the form submission?

Use $fetch inside the submit handler. useFetch is a composable designed for data-loading during render and SSR; it auto-runs on setup, dedupes, and integrates with Nuxt's payload. A form submission is an imperative user action, not a render-time data fetch. $fetch (which is ofetch under the hood) is the right tool: you call it on click, await the response, and update reactive state with the result. Reserve useFetch for GET requests that fetch initial data.

How do I keep my splitforms access key out of the bundle?

You don't have to hide it. The access key is a public submission token, not a secret. It's safe to ship in client JS the same way Stripe's publishable key or Google Maps API key is. If you still want to scope it, set Allowed Domains in the splitforms dashboard so only your production domain can submit. For runtime injection use Nuxt's useRuntimeConfig() with NUXT_PUBLIC_SPLITFORMS_KEY in your env and read it via config.public.splitformsKey.

Why am I getting a hydration mismatch on my form?

Almost always it's because you're rendering something that differs between server and client — like Date.now(), Math.random(), or a value from window. For forms specifically, watch out for autocompleted values: browsers fill inputs before Vue hydrates and the value diff trips the warning. Fix it by wrapping client-only sections in <ClientOnly>, or by setting suppressHydrationWarning on inputs you know will be auto-filled. Static form HTML rarely mismatches.

Will Vee-Validate work in a Nuxt 3 setup script?

Yes. Install vee-validate and yup (or zod), then use the useForm() and useField() composables inside <script setup>. They're fully compatible with Nuxt 3 and SSR. The schema-based validation pattern means you write one yup schema and Vee-Validate handles error state, dirty/touched tracking, and submission gating. For most contact forms it's overkill — three vanilla refs and an if-statement work fine. Reach for Vee-Validate when you have 8+ fields or complex conditional rules.

What goes wrong when I deploy to Cloudflare Pages?

Two things typically. First, if you're using a Nitro server route, Cloudflare workers have a 50ms CPU limit on free plan and 30s on paid — fine for splitforms relay calls, tight if you're also writing to a DB. Second, request body size: Cloudflare caps at 100MB which is fine for text but watch file uploads. With splitforms you skip both because there's no server route — your static Nuxt build posts directly to splitforms.com. Deploy works on Pages, Vercel, Netlify, and any static host.

Do I still need CSRF tokens?

No, and this confuses a lot of people coming from Laravel or Rails. CSRF tokens protect session-authenticated endpoints from cross-site form submissions that ride on the user's cookie. splitforms' /api/submit isn't session-authenticated — it's authenticated by the access_key in the request body. There's no cookie to ride. The threat model is spam (handled by honeypot + AI classification) and abuse (handled by Allowed Domains + rate limits), not CSRF.

How do I show a success message without a page reload?

Bind a reactive ref like submitted = ref(false), set it to true after the await $fetch(...) resolves, and use v-if='submitted' to swap the form for a thank-you block. If you want a redirect instead, use Nuxt's navigateTo('/thanks') from the same handler. Avoid hard form action submissions in Nuxt — they break SPA navigation and lose your reactive state. The fetch + ref pattern is the idiomatic Nuxt 3 approach.

About the author
✻ ✻ ✻

Get your free contact form API key in 60 seconds.

1,000 free form submissions per month. No credit card. No SDK, no PHP, no plugin. Drop one POST endpoint in your form and submissions land in your inbox.

Generate access key →Read the docs
v0.1 · founders pricing locked in · early access open