Contact Form

A form that validates in the browser, posts JSON to an API route, and announces the result. Without JavaScript it is an ordinary form POST, and the field names reach the server unchanged.

Source on GitHub

Usage

examples/components/contact-form/contact-form.html

html
<contact-form />
html examples/components/contact-form/contact-form.html
<!-- @bascik
A contact form that validates in the browser and posts JSON to /api/contact (the starter site's src/api/contact.ts handles it). Without JavaScript it falls back to a normal form POST. Field names are preserved so the server receives name, email, and message.
-->
<style>
  .form {
    display: grid;
    gap: 1rem;
    max-width: 32rem;
    padding: 1.5rem;
    border-radius: var(--radius, 0.5rem);
    background: var(--color-surface, color-mix(in srgb, currentColor 8%, transparent));
  }

  .field {
    display: grid;
    gap: 0.25rem;
  }

  .input {
    padding: 0.5rem 0.75rem;
    border: 1px solid var(--color-border, color-mix(in srgb, currentColor 30%, transparent));
    border-radius: var(--radius, 0.5rem);
    background: var(--color-bg, Field);
    color: inherit;
    font: inherit;
  }

  .input:focus-visible {
    outline: 2px solid var(--color-accent, currentColor);
    outline-offset: 1px;
  }

  .input:user-invalid {
    outline: 2px solid var(--color-danger, #c62828);
  }

  .submit {
    justify-self: start;
    padding: 0.5rem 1.25rem;
    border: 0;
    border-radius: var(--radius, 0.5rem);
    background: var(--color-accent, #2457d6);
    color: var(--color-accent-contrast, #ffffff);
    font: inherit;
    font-weight: 600;
    cursor: pointer;
  }

  .submit:disabled {
    opacity: 0.6;
    cursor: progress;
  }

  .status {
    margin: 0;
    color: var(--color-muted, inherit);
  }
</style>
<!-- data-bascik-preserve="name" keeps the field names literal. Without it, Bascik scopes them per instance and the
     server would receive keys like bascik__contact-form__a1b2__email. -->
<form class="form" id="form" action="/api/contact" method="post" data-bascik-preserve="name">
  <div class="field">
    <label for="name">Name</label>
    <input class="input" id="name" name="name" autocomplete="name" required />
  </div>
  <div class="field">
    <label for="email">Email</label>
    <input class="input" id="email" name="email" type="email" autocomplete="email" required />
  </div>
  <div class="field">
    <label for="message">Message</label>
    <textarea class="input" id="message" name="message" rows="5" required minlength="10"></textarea>
  </div>
  <button class="submit" id="submit" type="submit">Send</button>
  <p class="status" id="status" role="status"></p>
</form>
<script>
  const form = document.getElementById('form');
  const submit = document.getElementById('submit');
  const status = document.getElementById('status');

  form.addEventListener('submit', async (event) => {
    event.preventDefault();
    if (!form.checkValidity()) {
      form.reportValidity();
      return;
    }
    submit.disabled = true;
    status.textContent = 'Sending...';
    try {
      const response = await fetch(form.action, {
        method: 'POST',
        headers: { 'content-type': 'application/json', accept: 'application/json' },
        body: JSON.stringify(Object.fromEntries(new FormData(form))),
      });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      form.reset();
      status.textContent = 'Thanks. Your message was sent.';
    } catch {
      status.textContent = 'Sorry, the message could not be sent. Please try again.';
    } finally {
      submit.disabled = false;
    }
  });
</script>
Output is shown unminified (HTML, CSS, JS, and identifier names) for readability.

This docs site is static, so it has no /api/contact route and sending shows the error message. The starter site in the Bascik repository includes the handler and runs it under bascik --server.

How It Works

  • data-bascik-preserve="name" on the <form> keeps name, email, and message literal. Bascik scopes name attributes per instance by default, so without it new FormData(form) would send keys such as bascik__contact-form__a1b2__email.
  • The form keeps native validation, so without JavaScript the browser still blocks an empty or malformed submit. With JavaScript, the script calls checkValidity() and reportValidity(), which shows the browser's messages and focuses the first invalid field. :user-invalid styles a field only after the visitor has interacted with it.
  • The script sends JSON with fetch, disables the button while sending, and writes the outcome to a role="status" element.

The API Route

The starter site's handler accepts both the JSON request and the no-JavaScript form post. It answers JSON with 201 or 422 and an errors list, and a form post with a 303 redirect back to the page with ?sent=1. validateContact() is exported so it can be unit tested.

ts examples/starter-site/src/api/contact.ts
// src/api/contact.ts: POST /api/contact. Runs under `bascik --server` or a serverless target, never on a static host.
// Accepts JSON (from the contact-form component's script) and a plain form POST (the no-JavaScript fallback).

const MAX_LENGTH = { name: 200, email: 320, message: 5000 } as const;
type Field = keyof typeof MAX_LENGTH;

export interface ContactMessage {
  name: string;
  email: string;
  message: string;
}

/** Validate untrusted input. Returns the cleaned message, or a list of problems. */
export function validateContact(input: Record<string, unknown>): { ok: true; value: ContactMessage } | { ok: false; errors: string[] } {
  const errors: string[] = [];
  const value = {} as ContactMessage;
  for (const field of Object.keys(MAX_LENGTH) as Field[]) {
    const raw = typeof input[field] === 'string' ? (input[field] as string).trim() : '';
    if (!raw) errors.push(`${field} is required`);
    else if (raw.length > MAX_LENGTH[field]) errors.push(`${field} is too long`);
    value[field] = raw;
  }
  if (value.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.email)) errors.push('email is not valid');
  if (value.message && value.message.length < 10) errors.push('message is too short');
  return errors.length ? { ok: false, errors } : { ok: true, value };
}

export const POST = async (request: Request): Promise<Response> => {
  const isJson = (request.headers.get('content-type') ?? '').includes('application/json');
  let input: unknown;
  try {
    input = isJson ? await request.json() : Object.fromEntries(await request.formData());
  } catch {
    return Response.json({ errors: ['invalid request body'] }, { status: 400 });
  }
  // JSON can be null, a string, or an array; only an object can hold the fields.
  if (typeof input !== 'object' || input === null || Array.isArray(input)) {
    return Response.json({ errors: ['expected an object with name, email, and message'] }, { status: 400 });
  }

  const result = validateContact(input as Record<string, unknown>);
  if (!result.ok) return Response.json({ errors: result.errors }, { status: 422 });

  // Replace this with your email or CRM provider. Keep API keys in process.env, never in client code.
  console.log(`[contact] message from ${result.value.email}`);

  // A browser without JavaScript posted a normal form: send it back to the contact page on this origin.
  // Never trust the Referer header here, it would make this an open redirect.
  if (!isJson) return Response.redirect(new URL('../contact?sent=1', request.url), 303);
  return Response.json({ ok: true }, { status: 201 });
};

API routes need bascik --server or a serverless target such as the Cloudflare adapter. On a static host, point the form's action at a form service and leave the handler out.

Adapt It

  • To send email, call your provider's HTTP API in POST after validation and read the API key from process.env. Never put the key in browser code.
  • For inline messages instead of the browser's bubbles, add a <p id="email-error"> per field, point aria-describedby at it, and fill it from input.validationMessage. Bascik rewrites aria-describedby along with the IDs.