Nuxt recipe
Nuxt 3 ships with H3 server routes and useFetch on the client. Pick the pattern that matches what you want to expose.
Direct submit from a page
Simplest case — page hits Formspring directly via $fetch. The endpoint is public; CORS is open.
<!-- pages/contact.vue -->
<script setup lang="ts">
const config = useRuntimeConfig();
const form = reactive({ email: '', message: '' });
const sent = ref(false);
const error = ref<string | null>(null);
async function submit() {
error.value = null;
try {
await $fetch(config.public.formspringUrl, {
method: 'POST',
body: form,
});
sent.value = true;
} catch (e: any) {
error.value = e.data?.message ?? 'Something went wrong.';
}
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="form.email" type="email" required />
<textarea v-model="form.message" required />
<button>Send</button>
<p v-if="sent">Thanks.</p>
<p v-if="error" class="err">{{ error }}</p>
</form>
</template>
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
formspringUrl: process.env.NUXT_PUBLIC_FORMSPRING_URL,
},
},
});
Server route as proxy
Use a server route when you want to add server-side metadata, do server validation, or hide the endpoint:
// server/api/contact.post.ts
export default defineEventHandler(async (event) => {
const body = await readBody(event);
const r = await $fetch.raw(useRuntimeConfig().formspringUrl, {
method: 'POST',
body: { ...body, _ip: getRequestIP(event), _ua: getRequestHeader(event, 'user-agent') },
});
return { ok: true, id: r._data?.id };
});
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
formspringUrl: process.env.NUXT_FORMSPRING_URL, // private, server-only
},
});
The page now hits /api/contact:
await $fetch('/api/contact', { method: 'POST', body: form });
Verify webhooks in a server route
H3 makes signature verification straightforward. Read the raw body before parsing:
// server/api/formspring-webhook.post.ts
import { createHmac, timingSafeEqual } from 'node:crypto';
export default defineEventHandler(async (event) => {
const sig = getRequestHeader(event, 'x-formspring-signature') ?? '';
const raw = await readRawBody(event);
if (!raw) {
throw createError({ statusCode: 400, statusMessage: 'no body' });
}
const expected = createHmac('sha256', useRuntimeConfig().webhookSecret)
.update(raw)
.digest('hex');
if (
sig.length !== expected.length ||
!timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
) {
throw createError({ statusCode: 401, statusMessage: 'invalid signature' });
}
const body = JSON.parse(raw);
// …handle event
return { ok: true };
});
Set webhookSecret in runtimeConfig (top-level, server-only).
File uploads
Multipart needs special handling — Nuxt's readBody doesn't decode multipart by default. Use readMultipartFormData:
// server/api/upload-contact.post.ts
export default defineEventHandler(async (event) => {
const parts = await readMultipartFormData(event);
// parts is an array of { name, filename, type, data }
const fd = new FormData();
for (const p of parts ?? []) {
if (p.filename) {
fd.append(p.name!, new Blob([p.data], { type: p.type }), p.filename);
} else {
fd.append(p.name!, p.data.toString());
}
}
const r = await $fetch(useRuntimeConfig().formspringUrl, {
method: 'POST',
body: fd,
});
return r;
});
Deploy targets
Nuxt 3 deploys to Node, Vercel, Netlify, Cloudflare, Deno Deploy. The handlers above work everywhere; on Cloudflare Workers, swap node:crypto for the Web Crypto equivalent.