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.
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.