# Nuxt

> A Nuxt component that posts as a plain form before hydration and with $fetch after, with the form id in runtime config. Works with nuxt generate.

Nuxt renders the form on the server, so the HTML that reaches the browser is already a working form. Until the app hydrates it posts like any HTML form; after that, the component takes over and shows the result in place. Nothing runs on your server, so it also works as a static site built with `nuxt generate`.

To post from a Nuxt server route with a secret key instead, see [Vue](https://ikiform.com/docs/guides/vue.md#nuxt).

## Set it up

### Step 1: Put the form id in runtime config

`nuxt.config.ts`:

```ts
export default defineNuxtConfig({
  runtimeConfig: {
    public: {
      ikiformFormId: "frm_xxxxxxxxxxxxxxxx",
    },
  },
});
```

Override it per environment with `NUXT_PUBLIC_IKIFORM_FORM_ID`. With `nuxt generate`, the value is baked in at build time, so set the variable in the build environment.

### Step 2: Add the component

`app/components/ContactForm.vue`:

```vue
<script setup lang="ts">
type Result =
  | { ok: true; id: string }
  | { ok: false; error: { message: string; fields?: Record<string, string[]> } };

const config = useRuntimeConfig();
const endpoint = `https://ikiform.com/f/${config.public.ikiformFormId}`;

const status = ref<"idle" | "sending" | "sent" | "error">("idle");
const errors = ref<Record<string, string[]>>({});
const message = ref("");

async function onSubmit(event: Event) {
  status.value = "sending";
  const result = await $fetch<Result>(endpoint, {
    method: "POST",
    headers: { Accept: "application/json" },
    body: new FormData(event.currentTarget as HTMLFormElement),
    ignoreResponseError: true,
  }).catch(() => null);
  if (result?.ok) {
    status.value = "sent";
    return;
  }
  errors.value = result?.error.fields ?? {};
  message.value = result?.error.message ?? "Couldn't reach the server. Try again.";
  status.value = "error";
}
</script>

<template>
  <p v-if="status === 'sent'">Thanks, we got it.</p>
  <form v-else :action="endpoint" method="post" @submit.prevent="onSubmit">
    <label>
      Email
      <input name="email" type="email" autocomplete="email" required />
    </label>
    <p v-if="errors.email">{{ errors.email[0] }}</p>
    <label>
      Message
      <textarea name="message" rows="5" />
    </label>
    <input type="hidden" name="_redirect" value="https://example.com/thanks" />
    <input name="_gotcha" type="text" tabindex="-1" autocomplete="off" hidden />
    <p v-if="status === 'error' && !errors.email" role="alert">{{ message }}</p>
    <button :disabled="status === 'sending'">{{ status === "sending" ? "Sending" : "Send" }}</button>
  </form>
</template>
```

- On Nuxt 3, put it in `components/` instead of `app/components/`.
- `ignoreResponseError: true` stops `$fetch` throwing on `4xx`, so the error envelope comes back as data. Only network failures reach `catch`.
- `@submit.prevent` only exists once the app has hydrated. Before that, the browser posts to `action` and follows `_redirect`.

### Step 3: Use it on a page

`app/pages/contact.vue`:

```vue
<template>
  <ContactForm />
</template>
```

The fallback needs server rendering or prerendering. With `ssr: false`, the page is empty until JavaScript runs, so there is no form to post without it.

## Allowed domains

List production plus the preview hosts of wherever you deploy.

| Where the form runs | Add to Allowed domains |
| --- | --- |
| `nuxt dev` | `localhost`. It matches `http://localhost:3000`: Ikiform compares the host name only, never the port. |
| Your domain | `example.com`, which also covers `www.example.com` |
| Vercel | `your-project.vercel.app`. Preview URLs such as `your-project-git-branch-team.vercel.app` don't match it; add the exact preview host while you test one. |
| Netlify | `your-site.netlify.app`. Deploy previews (`deploy-preview-12--your-site.netlify.app`) and branch deploys (`branch--your-site.netlify.app`) are not subdomains of it, so add the exact preview host while you test one. |
| Cloudflare Pages | `your-project.pages.dev`. Preview URLs such as `abc123.your-project.pages.dev` are subdomains of it, so they already match. |

- Never add `vercel.app`, `netlify.app` or `pages.dev` on its own. Every site on that host would match, including other people's.

## Test checklist

1. Run `nuxt dev` with `localhost` in allowed domains and submit. The thanks message should replace the form.
2. Turn JavaScript off in your browser's dev tools, reload and submit. You should land on Ikiform's thank-you page locally, or `/thanks` on your `https:` domain.
3. Submit an invalid email with fields defined and confirm the message appears under the field.
4. Run `nuxt generate`, deploy the output, and submit from the preview host.

## Troubleshooting

| What you see | Why | Fix |
| --- | --- | --- |
| `FetchError: [POST] "https://ikiform.com/f/...": 422` | `ignoreResponseError` is missing, so `$fetch` throws on errors | Add `ignoreResponseError: true`. |
| `404 not_found`, and the action ends in `/f/undefined` | The form id wasn't set when the site was built | Set `NUXT_PUBLIC_IKIFORM_FORM_ID` in the build environment, or the default in `nuxt.config.ts`. |
| Without JavaScript, nothing happens | The page is client-rendered with `ssr: false` | Enable server rendering or prerender the page. |
| Submissions arrive under the spam filter with `origin_mismatch` | The page that posted isn't on the form's allowed domains | Add the host from the table above. With **Reject** chosen, the same posts get `403 origin_not_allowed` instead. |

## Related

- [Vue](https://ikiform.com/docs/guides/vue.md): A single-file component.
- [Plain JavaScript](https://ikiform.com/docs/guides/javascript.md): `fetch` with inline errors.
- [Allowed domains](https://ikiform.com/docs/spam.md#allowed-domains): How origins are matched, and what a mismatch does.
- [Errors](https://ikiform.com/docs/errors.md): Every error code, with its cause and fix.
