Back to blog

August 29, 2026 · NBForms Team

Nuxt Contact Form With No Server Route — And Why $fetch Isn't the Right Tool Here

Nuxt gives a contact form two built-in ways to reach a backend: a server route, or $fetch. The right answer for a simple form is neither — here's why, precisely.

Nuxt hands a contact form two ready-made ways to reach a backend: write a Nitro server route under server/api/, or call $fetch against one that already exists somewhere. Both exist for good reasons elsewhere in a Nuxt app — a route that reads from this app's own database, applies its own business rules, or needs server-only secrets earns its place. Neither is the right tool for "collect a name, an email, and a message, then tell me about it" — and reaching for either anyway adds a route to maintain, secure, and keep working, for a job an external endpoint already does.

Close-up of server cooling fans lit in blue and purple Photo by Winston Chen on Unsplash

The whole component

<template>
  <p v-if="success">Thanks — we'll be in touch!</p>
  <form v-else @submit.prevent="submit">
    <input type="hidden" name="_token" value="YOUR_TOKEN" />
    <label>Name</label>
    <input type="text" name="name" required />
    <label>Email</label>
    <input type="email" name="email" required />
    <label>Message</label>
    <textarea name="message" required></textarea>
    <button type="submit" :disabled="sending">
      {{ sending ? 'Sending…' : 'Send' }}
    </button>
  </form>
</template>

<script setup>
const sending = ref(false)
const success = ref(false)

async function submit(e) {
  sending.value = true
  const res = await fetch('https://api.nbforms.com', {
    method: 'POST',
    body: new FormData(e.target),
  })
  success.value = res.ok
  sending.value = false
}
</script>

Nuxt auto-imports ref from Vue automatically, so there's no import { ref } from 'vue' line at all — one line shorter than the identical component in plain Vue, and the only difference between the two. The _token hidden field ties the request to a specific NBForms form, generated once from that form's setup page; every other field name becomes a submissions column automatically, with no server route, no schema, and no migration to write first.

Why not $fetch

Reaching for $fetch in place of fetch here looks like the more "Nuxt" choice, and it's the wrong one for this specific line of code. $fetch (built on ofetch) auto-parses JSON and, on a non-2xx response, throws instead of returning a response object with an .ok flag to check. success.value = res.ok has nothing to read if res is either a thrown error or an already- parsed JSON body — the line would need rewriting into a try/catch, checking for an error rather than a falsy flag. Plain fetch keeps the shape this snippet already uses, in a Nuxt app or anywhere else; the choice isn't an oversight, it's $fetch's own error-handling model being the wrong fit for a boolean success check.

Why not useFetch either

useFetch solves a different problem: data a component needs as soon as it renders, wired into Nuxt's SSR payload transfer and Suspense, re-running automatically when a reactive value it depends on changes. A form submission is the opposite of that shape — it's a one-off request triggered by a click, not data the component needs before it can render at all. Calling useFetch from inside submit works against the composable's own design (and Nuxt's linter will flag a composable called outside setup for exactly this reason) rather than saving anything over a direct fetch call.

Every rendering mode, unchanged

Nothing about the component above runs any differently depending on how the page it's on was rendered. A route prerendered at build time (nitro.prerender.routes), one rendered per-request on the server, one running with ssr: false as a pure SPA, or one covered by a hybrid routeRules entry — the submission still only happens in the browser, after a real click, well after whatever rendering Nuxt did to produce the page is finished. There's no server-side render path this form submission ever touches, so none of Nuxt's rendering configuration changes what it does.

Not a secret, so runtimeConfig doesn't apply

Anything that looks like a credential in a Nuxt component usually raises the same instinct: move it into runtimeConfig, keep it out of the client bundle, read it server-side only. The _token value here doesn't need that treatment, because it isn't a credential in that sense — it identifies which NBForms form a submission belongs to, comparable to a public form ID, not a key that grants access to anything if someone else sees it. It's meant to sit directly in the component's markup, visible in the rendered HTML exactly like it would be in a plain static site's <form> tag. Reaching for runtimeConfig here doesn't add protection; it adds a round trip through a server value for something that was never meant to be private.

Sidestepping a whole class of middleware bugs

Plenty of real Nuxt apps run a global Nitro middleware in server/middleware/ that checks authentication on every request under /api/* before a specific route ever runs — a reasonable default for an app whose API mostly serves logged-in users. A new server/api/contact.post.ts route dropped into a codebase like that inherits whatever that middleware decided, and if it wasn't added to an allowlist of public routes, a visitor filling in a contact form gets a silent 401 instead of a sent message — a bug that's easy to ship and confusing to debug precisely because the route's own code is correct; the block happens one layer above it, often in a file nobody thought to check while debugging the form itself. Posting straight to an external endpoint never enters that middleware chain at all, so this entire category of bug has nothing to attach to — not because it was fixed, but because there's no local route for that middleware to intercept in the first place.

One less route to secure

A server/api/contact.post.ts route, however small, is still a route: something that needs its own input validation, its own protection against being hit directly with a crafted payload instead of a real form submission, and its own place in whatever monitoring covers the rest of the app's API surface. None of that disappears because the handler is short — a route that emails you when it receives a POST is still a route a bad actor can probe. Skipping it for a contact form isn't just less code to write; it's one less thing in the app's own attack surface to think about at all.

What it doesn't do

Same scope as any lightweight form backend: this receives a submission, filters it for spam automatically, stores it, and sends an email alert. No payment processing, no multi-step form logic, and no automatic reply beyond that alert and whatever the component shows on success. A form that genuinely needs custom server-side logic — writing to this app's own database in a particular shape, triggering an internal workflow — is exactly the case a real server/api/ route is still the right call for; a contact form asking to be read usually isn't that — the two aren't in tension, they're just answers to different questions about what a given request needs to do once it arrives.

The Nuxt snippet reference has this code with no prose around it. The redirect-setting gotcha covered in the Next.js form backend post applies here too, unrelated to $fetch or useFetch — it comes from how fetch itself handles redirects, and Nuxt doesn't change that behavior. The same reasoning about $fetch's throw-on-error model is worth keeping in mind anywhere else $fetch gets substituted for fetch on instinct rather than by checking what the substitution actually changes.

Frequently asked questions

Why does this snippet use fetch instead of Nuxt's own $fetch?

Because $fetch changes what a failed request looks like — it throws instead of returning a response with an .ok flag, which would break the success = res.ok line above without a rewrite. Plain fetch keeps the same shape Nuxt or not.

Could useFetch be used here instead?

No — useFetch is meant to run during a component's setup, tied to SSR and reactive re-fetching, not to be called imperatively from inside a submit handler. Calling it there works against what it's designed for rather than with it.

Does this work in SSR, SSG, and hybrid-rendered Nuxt apps the same way?

Yes. The submission itself only ever runs in the browser, triggered by a click — nothing about it executes during server rendering or prerendering, so it's identical regardless of how the page itself was rendered.

Do I need to import ref for this to work?

No — Nuxt auto-imports ref, computed, and the rest of Vue's Composition API automatically, so the component is one import shorter than the same code in plain Vue.

Does this need a server route (server/api/*) at all?

No, and that's the point — nothing about receiving, storing, filtering, or alerting on a submission runs inside this Nuxt app. The request goes straight from the browser to NBForms' endpoint.