All docs
3 min read

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.

What's next