Skip to content
← All frameworks
env + html + ts · Vite

Build tool

Vite form handling without a backend

Vite is a dev server and build tool rather than a framework. Its vanilla-ts template gives you an index.html and a src/main.ts, and vite build turns them into static files, so a contact form needs somewhere outside the app to send its data. Formward is that place: the form posts to one URL, and the submission is spam-filtered, stored in Sweden and emailed to you.

The example keeps the endpoint in a .env file. Vite puts it into the HTML with its %VITE_...% placeholder, so the form posts even without JavaScript, and into main.ts through import.meta.env, where a fetch handler keeps the visitor on the page. Using React, Vue, Svelte or Solid on Vite? Each has its own page with the component code; the env file below works the same in all of them.

EU-hosted · vite build gives you static files you can host anywhere. The form data they collect is received and stored by Formward in Sweden.

The Vite example

Copy this into your project and replace <FORM_ID> with the id of a form you create in the Formward dashboard.

env + html + ts

# .env (next to index.html)
VITE_FORMWARD_ENDPOINT=https://forms.formward.eu/f/<FORM_ID>

<!-- index.html -->
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Contact</title>
  </head>
  <body>
    <form id="contact" action="%VITE_FORMWARD_ENDPOINT%" method="POST">
      <label>Name <input name="name" required /></label>
      <label>Email <input type="email" name="email" required /></label>
      <label>Message <textarea name="message" required></textarea></label>
      <!-- Honeypot: real users never see or fill this. -->
      <input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
        style="position:absolute;left:-5000px" aria-hidden="true" />
      <button type="submit">Send</button>
      <p id="status" role="status"></p>
    </form>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

// src/main.ts
const ENDPOINT = import.meta.env.VITE_FORMWARD_ENDPOINT;

const form = document.querySelector<HTMLFormElement>("#contact")!;
const status = document.querySelector<HTMLParagraphElement>("#status")!;
const button = form.querySelector<HTMLButtonElement>("button[type=submit]")!;

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  button.disabled = true;
  status.textContent = "Sending...";

  try {
    const res = await fetch(ENDPOINT, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: new FormData(form),
    });
    const data: { ok: boolean; error?: string } = await res.json();
    if (!res.ok || !data.ok) {
      status.textContent = res.status === 429
        ? "Too many attempts. Wait a minute and try again."
        : "Could not send. Please try again.";
      return;
    }
    form.reset();
    status.textContent = "Thanks, we will reply by email.";
  } catch {
    status.textContent = "Could not send. Check your connection and try again.";
  } finally {
    button.disabled = false;
  }
});

Success and error handling

  • Only variables that start with VITE_ reach client code, and Vite writes their values into the bundle at build time. That is fine here, because a form endpoint is a public URL; never put an API key in a VITE_ variable.
  • Vite replaces %VITE_FORMWARD_ENDPOINT% in index.html during dev and build. If the variable is missing, Vite leaves the placeholder as it is, so a form whose action still reads %VITE_FORMWARD_ENDPOINT% means the .env file was not picked up.
  • The Accept: application/json header switches Formward from its 302 redirect to JSON. Errors come back as { ok: false, error } with 422 for a failed field rule, 429 for too many submissions from one address, 402 when the account is over its plan limit and 403 for an origin outside the form's allowlist.
  • Without JavaScript the form still posts to the action and Formward redirects to its thank-you page. Add a hidden _redirect field with a path such as /thanks.html to bring visitors back to your site instead.

Type the variable

The template's TypeScript setup already includes Vite's client types, which declare import.meta.env. Declaring your own variable there gives you a string type and autocompletion instead of any. Put the declaration in the file that references vite/client, usually src/vite-env.d.ts, or add the file if your template has none.

ts

// src/vite-env.d.ts
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_FORMWARD_ENDPOINT: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

A separate form for local testing

Vite loads .env.development when you run vite and .env.production when you run vite build, on top of .env. Create a second form in the dashboard for testing and put its endpoint in .env.development, so test submissions stay out of the live form's inbox and exports.

env

# .env.development (used by the dev server)
VITE_FORMWARD_ENDPOINT=https://forms.formward.eu/f/<TEST_FORM_ID>

# .env.production (used by vite build)
VITE_FORMWARD_ENDPOINT=https://forms.formward.eu/f/<FORM_ID>

Spam protection

Every example above includes the _gotcha honeypot field. It is hidden from real users and must stay empty. When a bot fills it, Formward files the submission as spam and still answers with a normal success response, so the bot learns nothing and nothing reaches your inbox. Rate limits and the origin allowlist are checked on Formward's side as well. For a stricter gate, switch on Cloudflare Turnstile in the form's settings and add the widget; it adds a cf-turnstile-response field that Formward verifies on receipt. Turnstile is optional, and Cloudflare is a US provider, listed as such in the sub-processor register. The spam docs have the widget markup.

The JSON response

A standard POST gets a 302 redirect (or your thank-you page). Send the Accept: application/json header - as every fetch() example above does - and Formward returns JSON instead:

HTTP/1.1 200 OK
Content-Type: application/json

{ "ok": true, "id": "3f2a9c1e-...", "files": 0, "success": { "title": "Thank you!", ... } }

files is the number of uploaded files stored and success carries the confirmation text from the form's settings. When you set a redirect URL for the form, the reply also has a redirect field your code can send the visitor to. On failure the body is { ok: false, error } with a 4xx status: 422 when required consent is missing or a field is too long, 403 when a Turnstile check fails, 429 for too many submissions from one address, 402 when the account is over its plan limit. See responses and redirects for the full table and the AJAX docs for CORS.

Other frameworks

Collect your first Vite submission

Create a form, paste the snippet, and keep every submission in the EU. GDPR-clean from the first POST.

Vite contact form without a backend | Formward