---
title: "Add a form to Vue, Svelte, Astro, or HTML"
description: "Mount a Fillo form with @usefillo/dom in Vue, Svelte, Astro, or a plain HTML page, and destroy it cleanly when the component unmounts."
order: 3
type: "guide"
topic: "Frameworks"
tags:
  - "Vue"
  - "Svelte"
  - "Astro"
  - "HTML"
  - "JavaScript"
  - "DOM renderer"
  - "embed"
keywords:
  - "vue form"
  - "svelte form"
  - "astro form"
  - "html form javascript"
updated: "2026-09-27"
---

Render a Fillo form as native elements in a Vue, Svelte, or Astro app, or on a plain HTML page. Every framework uses the same `@usefillo/dom` pattern. Mount the form with `renderForm()`, and call `destroy()` when the component unmounts.

## The shared pattern

Install the package in a bundled app:

```bash
npm install @usefillo/dom
```

`renderForm(target, options)` mounts the form into one element and returns a handle. The renderer owns that element's children, so don't let your framework render inside it.

| Option or method | Use |
| --- | --- |
| `formId` | A published form ID or slug. No key needed. |
| `form` and `client` | A form from `defineForm()` that saves responses through your `pk_` key |
| `onSubmitted` | Runs after Fillo records the response |
| `initialData` | Prefill answers by field ID |
| `theme` | Design tokens for the default renderer |
| `destroy()` | Removes the form and cancels in-flight uploads. Call it on unmount. |
| `setRespondent()` | Adds signed-in account context after your session loads |

When the form ID changes, destroy the current form before you mount the next one.

## Vue

```vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from "vue";
import { renderForm, type FilloDomForm } from "@usefillo/dom";
import "@usefillo/dom/styles.css";

const root = ref<HTMLElement | null>(null);
let form: FilloDomForm | undefined;

onMounted(() => {
  if (!root.value) return;
  form = renderForm(root.value, {
    formId: "your-published-form-id",
    onSubmitted: (responseId) => console.info("Fillo response", responseId),
  });
});

onBeforeUnmount(() => form?.destroy());
</script>

<template>
  <section aria-labelledby="feedback-title">
    <h2 id="feedback-title">Product feedback</h2>
    <div ref="root" />
  </section>
</template>
```

If a route reuses the component for another form, destroy and remount in a `watch` on the form ID.

## Svelte

```svelte
<script lang="ts">
  import { onMount } from "svelte";
  import { renderForm } from "@usefillo/dom";
  import "@usefillo/dom/styles.css";

  let root: HTMLDivElement;

  onMount(() => {
    const form = renderForm(root, {
      formId: "your-published-form-id",
      onSubmitted: (responseId) => console.info("Fillo response", responseId),
    });
    return () => form.destroy();
  });
</script>

<section aria-labelledby="feedback-title">
  <h2 id="feedback-title">Product feedback</h2>
  <div bind:this={root}></div>
</section>
```

`onMount` runs only in the browser, so server rendering never touches the form. For fully Svelte-owned markup, use `createFormController()` from the same package and see [custom UI](/docs/custom-ui).

## Astro

The page stays server-rendered. A client script mounts the form into one element.

```astro
---
import "@usefillo/dom/styles.css";

const { formId = "your-published-form-id" } = Astro.props;
---

<section aria-labelledby="feedback-title">
  <h2 id="feedback-title">Product feedback</h2>
  <div id="fillo-feedback" data-form-id={formId}></div>
</section>

<script>
  import { renderForm } from "@usefillo/dom";

  const root = document.querySelector<HTMLElement>("#fillo-feedback");
  if (root) {
    renderForm(root, {
      formId: root.dataset.formId!,
      onSubmitted: (responseId) => console.info("Fillo response", responseId),
    });
  }
</script>
```

Give each instance on a page its own element ID. With view transitions, mount on `astro:page-load` and call `destroy()` on `astro:before-swap`.

## HTML

No package manager or build step. The standalone bundle exposes a global `Fillo` object:

```html
<link rel="stylesheet" href="https://unpkg.com/@usefillo/dom@0.23/dist/styles.css" />

<section aria-labelledby="contact-title">
  <h2 id="contact-title">Contact us</h2>
  <div id="fillo-contact"></div>
</section>

<script src="https://unpkg.com/@usefillo/dom@0.23/dist/standalone.global.js"></script>
<script>
  Fillo.renderForm("#fillo-contact", {
    formId: "your-published-form-id",
    onSubmitted: function (responseId) {
      console.info("Fillo response", responseId);
    },
  });
</script>
```

Use the version pinned in the [embed quickstart](/docs/embed#frameworks), and test upgrades before you change it. The bundle also registers a `<fillo-form form-id="…">` element.

If your site sets a Content Security Policy, allow these origins:

| Directive | Origin |
| --- | --- |
| `script-src` and `style-src` | `https://unpkg.com` |
| `connect-src` | `https://fillo.so`, or your `baseUrl` |
| `frame-src` | `https://fillo.so`, when the form uses the human check |

Serve the page over HTTPS. When you can run a build, prefer the npm package for version locking and types.

## Keep secrets out of the page

A published form ID is safe in page HTML. Ship a `pk_` publishable key only when a code-defined form needs it. Never put an `fsk_` or `fcli_` token, an identity secret, a webhook secret, or storage credentials in client code.

## Check it before you ship

1. Load the form, then load an unpublished form ID and read the error.
2. Submit empty and confirm focus moves to the first error.
3. Navigate away mid-upload, come back, and check for leftover forms.
4. Submit with the network off, then retry without retyping.
5. Find the saved response in Fillo.

## Related

- [Contact example](/examples#contact): See the validation, loading, and success states in the browser.
- [Embed quickstart](/docs/embed): Compare the DOM renderer, the web component, and the standalone bundle.
- [Styling](/docs/styling): Override tokens, slots, and state attributes.
- [Blocked by CORS policy](/docs/troubleshooting/cors-error): Configure API and storage origins.
