splitforms.com
NUXT · CONTACT FORM

Contact form for Nuxt apps

Nuxt 3, Nuxt Content, Nuxt Studio, Nuxt UI — all flavors work. Use a Vue 3 Composition API page, pull your key from runtimeConfig.public, and skip writing a `/server/api` route. Or use a Nitro server route to keep the key server-side. Both patterns supported.

1,000 free submissions every month.·No credit card.
contact.vuevue36 lines
01<!-- pages/contact.vue -->
02<script setup>
03import { ref } from "vue";
04
05const status = ref("idle");
06// Expose the key via runtimeConfig.public in nuxt.config.ts
07const config = useRuntimeConfig();
08
09async function onSubmit(e) {
10 status.value = "loading";
11 const formData = new FormData(e.target);
12 formData.append("access_key", config.public.splitformsKey);
13
14 const res = await fetch("https://splitforms.com/api/submit", {
15 method: "POST",
16 body: formData,
17 });
18 const data = await res.json();
19 status.value = data.success ? "ok" : "err";
20 if (data.success) e.target.reset();
21}
22</script>
23
24<template>
25 <form @submit.prevent="onSubmit">
26 <input name="name" placeholder="Name" required />
27 <input name="email" type="email" placeholder="Email" required />
28 <textarea name="message" placeholder="Message" required />
29 <input type="checkbox" name="botcheck" style="display:none" tabindex="-1" />
30 <button :disabled="status === 'loading'">
31 {{ status === 'loading' ? 'Sending…' : 'Send' }}
32 </button>
33 <p v-if="status === 'ok'">Thanks!</p>
34 <p v-if="status === 'err'">Error.</p>
35 </form>
36</template>
1,000
submissions / mo, free
14ms
median latency, edge
0
lines of backend code
17+
frameworks supported
✶ Live preview

What your Nuxt contact form actually looks like.

Drop-in form backend with spam filtering, signed webhooks, and a real submissions dashboard. The same code in this preview is what you copy into your Nuxt project — no SDK, no plugin, no PHP.

  • 1,000 submissions per month, free forever
  • Honeypot + AI spam classifier on every plan
  • Signed webhooks to Slack, Discord, your server
Nuxt contact form on Splitforms — drop-in form backend with spam filtering and webhooks
§ 01Setup3 steps · 60 seconds · zero config

Ship a Nuxt contact form without a backend.

No SDK, no PHP, no plugin. Your form posts standard FormData to one URL — submissions land in your inbox.

STEP 01GENERATE

Get your free access key

Verify your email and your access key is generated instantly. Free for 1,000 submissions per month, forever.

Create your form

By signing up, you agree to our terms and privacy policy.

STEP 02EMBED

Drop in the Nuxt code

Copy the Nuxt snippet on the right and paste it into your project. Replace YOUR_ACCESS_KEY with the key from step 1.

snippetvue
<!-- pages/contact.vue -->
…
STEP 03RECEIVE

Submissions land in your inbox

Hits your dashboard and email in seconds. Forward to Slack, Discord, Sheets, Notion, or any signed webhook URL.

inbox · 1 newjust now
FROM contact@yoursite.com
New Nuxt form submission
Maya Iyer maya@studio71.co
Loved the new pricing page — quick question about the 4-year plan. Are usage limits per project or account-wide?
§ 02Live demosandboxed · no key required · no submission sent

Try it now — no signup, no key.

This is a styled HTML preview of what your Nuxt form will look like. Submitting opens a confirmation, no real request is sent.

preview · nuxtlocalhost:3000
✦ what just happened

Your Nuxt form posts FormData to /api/submit. Splitforms validates the access key, runs the spam classifier, and forwards the parsed submission to your inbox plus the dashboard.

  • 14ms median round-trip from the edge.
  • Honeypot + classifier, no CAPTCHA.
  • Per-domain key locking out of the box.
REQUEST · POST /api/submit
{
  "access_key": "sk_live_4f9a_••••",
  "name":       "Maya Iyer",
  "email":      "maya@studio71.co",
  "message":    "…"
}
← 200 OK · { "success": true } · 14ms
§ 03Best practices5 rules · production-tested

How to ship this without regrets.

Five rules that make the difference between a form that works in the demo and a form that survives launch traffic.

  1. 01

    Use the client-side fetch pattern (the snippet above) — fewer moving parts, works on every Nitro preset, no server CPU time used.

  2. 02

    Define `splitformsKey` in `nuxt.config.ts` under `runtimeConfig.public`. Read from `process.env.NUXT_PUBLIC_SPLITFORMS_KEY` so it's environment-driven.

  3. 03

    If you must hide the key, use a `/server/api/contact.post.ts` Nitro route — define the key under `runtimeConfig` (not `.public`) so it stays server-side.

  4. 04

    After successful submit, `await navigateTo('/thanks')` rather than just resetting the form. Nuxt's router gives you a clean redirect with no full page reload.

  5. 05

    Lock the access key to your domain in the splitforms dashboard. Nuxt's local dev (`localhost:3000`) needs to be in allowed domains for testing — or use a separate dev key.

§ 04Common gotchas in Nuxt6 edge cases worth knowing

What bites people who skip the docs.

Worth a 60-second skim before you ship to production. Each one has caused a Nuxt support ticket at least once.

⚠ gotcha

runtimeConfig.public is required for client exposure — `runtimeConfig.x` is server-only

Nuxt's runtimeConfig has two scopes. Anything under runtimeConfig.public is exposed to the browser bundle; anything else is server-only. If you put splitformsKey directly in runtimeConfig (not .public), useRuntimeConfig().splitformsKey returns undefined client-side.

⚠ gotcha

useRuntimeConfig() called outside setup() returns empty object

Calling useRuntimeConfig() inside a regular function (not inside <script setup> or a composable) returns {}. Always call it at the top of <script setup> and capture the value, then use config.public.splitformsKey inside event handlers.

⚠ gotcha

Nitro Cloudflare preset has 10ms CPU time on the free plan

If you proxy the splitforms call through a Nuxt server route (/server/api/contact.post.ts), the fetch round-trip eats your Cloudflare Worker CPU budget. On Cloudflare Pages Free tier, this can fail under load. Skip the proxy: have the form POST directly to splitforms.com.

⚠ gotcha

Nuxt Content's <ContentDoc> rendering can break form HTML

If you embed the form in a Markdown file rendered by Nuxt Content, MDC syntax may interpret your inputs as MDC components. Wrap the form in <NuxtContent> (or use :component="ContactForm") instead of inline form HTML.

⚠ gotcha

useFetch / $fetch differ from native fetch on body handling

Nuxt's $fetch('/url', { body: formData }) automatically sets Content-Type: multipart/form-data AND tries to JSON-stringify your FormData. Use the native fetch() API in the form handler — $fetch is for typed Nitro routes, not third-party endpoints.

⚠ gotcha

Payload extraction (Nuxt 3 SSG) inlines form state into the prerendered HTML

When you run nuxt generate for SSG, Nuxt's payload extractor serializes any reactive ref() values referenced during prerender into a __NUXT__ global on the static HTML — including the initial state of your form fields if they're populated server-side from useAsyncData. If you've prefilled a name ref from a logged-in user fixture, every visitor sees that fixture's value on first paint until hydration overrides it. Keep form refs initialized to empty strings on the server, or wrap the form in <ClientOnly> so SSG doesn't snapshot any default state at all.

§ 04bNative Nuxt forms…and where they break down

How Nuxt handles forms without splitforms.

The shape of the problem before splitforms enters the picture — and the gap it fills for Nuxt specifically.

Nuxt 3's Nitro server gives you /server/api/contact.post.ts for free — write a handler, parse FormData, send email, store the submission, fan out a webhook. The DX is good; the operational cost is the same as any framework: an SMTP integration (Nodemailer + a transactional provider), a Postgres or KV store for submissions, anti-spam logic (honeypot + classifier or hCaptcha), and webhook signing if you want secure delivery to Slack/Discord/Make.com. Each feature is another deploy artifact, another env var, another thing to monitor. Splitforms is the inverse: skip the /server/api route entirely (post directly from the page), or proxy through a one-line Nitro route to keep the key server-side.

§ 04cAlternative integration patterns2 ways to wire it

Two ways to ship splitforms on Nuxt.

Pick the pattern that matches your constraints — JS budget, key-exposure tolerance, server-side opacity. Both produce the same result.

PATTERN A

Pattern A — direct browser POST from `<script setup>`

No server route. Page reads the access key from useRuntimeConfig().public.splitformsKey. Works identically on every Nitro preset.

pattern-a.vuevue18 lines
01<script setup>
02import { ref } from "vue";
03const status = ref("idle");
04const config = useRuntimeConfig();
05async function onSubmit(e) {
06 status.value = "loading";
07 const fd = new FormData(e.target);
08 fd.append("access_key", config.public.splitformsKey);
09 const r = await fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
10 status.value = (await r.json()).success ? "ok" : "err";
11}
12</script>
13<template>
14 <form @submit.prevent="onSubmit">
15 <input name="email" type="email" required />
16 <button :disabled="status === 'loading'">Send</button>
17 </form>
18</template>
PATTERN B

Pattern B — Nitro server route (key stays server-side)

Page posts to /api/contact (a Nitro route) which appends the access key from runtimeConfig.splitformsKey (private) and proxies to splitforms. Adds a hop but the key never reaches the browser bundle.

pattern-b.vuevue11 lines
01// server/api/contact.post.ts
02export default defineEventHandler(async (event) => {
03 const config = useRuntimeConfig();
04 const parts = await readMultipartFormData(event);
05 const fd = new FormData();
06 for (const p of parts ?? []) p.name && fd.append(p.name, p.data.toString("utf8"));
07 fd.append("access_key", config.splitformsKey);
08 const res = await $fetch("https://splitforms.com/api/submit", { method: "POST", body: fd });
09 if (!res.success) throw createError({ statusCode: 400, message: res.message });
10 return { ok: true };
11});
§ 04dDeployment notes for Nuxthosting · env vars · CSP

Shipping Nuxt + splitforms to production.

Host-specific gotchas, env-var conventions, and the boring-but-load-bearing details for putting this on the public internet.

Nuxt's Nitro server abstracts away deployment — pick a preset (vercel, netlify, cloudflare-pages, cloudflare-workers, node-server, static, aws-lambda, digital-ocean) and ship. The form's POST is cross-origin to splitforms, so the preset doesn't affect delivery. On Cloudflare Workers (10ms CPU on free tier), strongly prefer Pattern A — Pattern B's $fetch round-trip can blow the budget under load. Use runtimeConfig.public.splitformsKey (client-exposed) for Pattern A and runtimeConfig.splitformsKey (server-only) for Pattern B. Both populate from NUXT_PUBLIC_SPLITFORMS_KEY / NUXT_SPLITFORMS_KEY env vars.

§ 05Comparisonvs native nuxt

splitforms vs native nuxt.

What you get for free vs what you build, pay for, or do without.

FeatureNative Nuxtsplitforms
Setup time/server/api route + email + spam (1 day)60 seconds
Nitro presetsEach preset has setup nuancesAll presets work the same
Spam filterDIYBuilt-in
Cold start (Cloudflare)Worker CPU time eaten by fetchDirect POST, no proxy
Submission storageDB + auth (Sidebase, Lucia)Dashboard included
CostHosting + email serviceFree (1,000/mo)
§ 06Alternative patternvue · 55 lines
ALTERNATIVE

Server route variant — hides the access key, keeps it off the bundle

If you'd rather not expose the access key client-side, proxy through a Nitro server route. Define the key under `runtimeConfig` (not `.public`) so it never reaches the browser.

alternative.vuevue55 lines
01// nuxt.config.ts
02export default defineNuxtConfig({
03 runtimeConfig: {
04 splitformsKey: "", // server-only, populated from NUXT_SPLITFORMS_KEY env
05 public: {
06 // (nothing — key is private now)
07 },
08 },
09});
10
11// server/api/contact.post.ts
12export default defineEventHandler(async (event) => {
13 const config = useRuntimeConfig();
14 const formData = await readMultipartFormData(event);
15
16 const body = new FormData();
17 for (const part of formData ?? []) {
18 if (part.name) body.append(part.name, part.data.toString("utf8"));
19 }
20 body.append("access_key", config.splitformsKey);
21
22 const res = await $fetch<{ success: boolean; message?: string }>(
23 "https://splitforms.com/api/submit",
24 { method: "POST", body }
25 );
26
27 if (!res.success) throw createError({ statusCode: 400, message: res.message });
28 return { ok: true };
29});
30
31// pages/contact.vue
32<script setup>
33const status = ref("idle");
34async function onSubmit(e) {
35 status.value = "loading";
36 const formData = new FormData(e.target);
37 try {
38 await $fetch("/api/contact", { method: "POST", body: formData });
39 status.value = "ok";
40 e.target.reset();
41 } catch {
42 status.value = "err";
43 }
44}
45</script>
46<template>
47 <form @submit.prevent="onSubmit">
48 <input name="name" required />
49 <input name="email" type="email" required />
50 <textarea name="message" required />
51 <button :disabled="status === 'loading'">Send</button>
52 <p v-if="status === 'ok'">Thanks!</p>
53 <p v-if="status === 'err'">Error.</p>
54 </form>
55</template>
§ 07Questions6 answered

Things developers ask before they integrate.

Direct answers, no marketing fluff. Missing one? Email hello@splitforms.com.

01How do I add a contact form to Nuxt 3?
Save the snippet above as pages/contact.vue. Add splitformsKey under runtimeConfig.public in nuxt.config.ts, populated from process.env.NUXT_PUBLIC_SPLITFORMS_KEY. Visit /contact — done. No /server/api route needed.
02Does splitforms work with Nuxt SSR / SSG / hybrid rendering?
Yes — all three modes. The form is client-side (uses fetch in a setup-script handler), so it works regardless of the page's render mode. SSR and SSG just affect how the form's wrapper page is generated.
03How do I handle form errors in Nuxt?
Use a status ref with four values: idle, loading, ok, err. Render error messages from the splitforms response inside v-if="status === 'err'". The fetch returns { success, message? } — use the message for user-facing errors.
04Can I use splitforms with Nuxt server routes / Nitro instead?
Yes — see the alternative-pattern snippet. Define the key as private under runtimeConfig and proxy through server/api/contact.post.ts. Adds latency but keeps the key entirely server-side.
05How do I customize the success / redirect behavior?
Two options. (1) Stay on-page with a Vue-rendered success message (default in our snippet). (2) After a successful fetch, call await navigateTo('/thanks') for a Nuxt-router redirect. Or use a hidden redirect field for non-AJAX server-side 302.
06Will splitforms work with Nuxt Content, Nuxt UI, or Nuxt Studio?
Yes — all three. Nuxt UI's <UForm> component works (it's just a styled <form>); Nuxt Content can embed the contact form via MDC syntax; Nuxt Studio's editor previews the form correctly. Same backend regardless.
✻ ✻ ✻

Ship your Nuxt contact form in 60 seconds.

1,000 free submissions per month. No credit card. Lock the access key to your domains, paste the snippet, watch submissions land in your inbox.

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