web / asyncHTML

asyncHTML is a small client library for building server-rendered web apps. HTML elements make asynchronous HTTP requests. The server responds with HTML fragments, and the library swaps them into the page. The library is one TypeScript file of about 500 lines, with no dependencies.

asyncHTML occupies the same space as htmx: hypermedia as the engine of application state without a client-side framework. The difference is a clearer contract. htmx offers many attributes that you must remember. In contrast, asyncHTML keeps the attribute set small and gives the rest to the server.

A response carries the HTML fragment and any JavaScript that runs with it, such as navigation and history updates. asyncHTML exposes helpers like pushURL. That JavaScript calls the helpers rather than encoding more attributes. A fragment groups structure and behavior in one template that looks like a Svelte file.

I wrote asyncHTML in an app with roots in early-2010s Ruby on Rails. The app used link_to path, remote: true, which rendered data-remote. A delegated UJS script then responded with .js.haml. asyncHTML keeps that model: small attributes, delegated listeners, and server-sent HTML with executable scripts.

This article documents that contract, using a Go and hml app as the source of truth.

Attributes

Elements opt in with ah- attributes. A link that loads a form:

%a{ "ah-get": "/notes/new_for_person?person_id=42" }
  + Note

Clicking the link sends GET /notes/new_for_person, and the response HTML runs. A response contains markup and any <script> tags to place it. The library injects the markup and runs the scripts. The scripts can perform any DOM operation. Wrap the markup in a <template> to prevent rendering until a script clones it. For example, into a drawer:

%template#tmp
  %form{ "ah-post": "/notes/create_for_person" }
    %input{ type: "hidden", name: "person_id", value: "42" }
    %textarea{ name: "comments" }
    %input{ type: "submit", value: "Save" }

:javascript
  APP.openDrawer("template#tmp");

Submitting the form sends POST /notes/create_for_person, and the response runs the same way.

The full vocabulary:

How the client behaves

The library attaches event listeners to document.body instead of binding each element. A swapped fragment becomes active immediately. Because of this delegation, ah-get-on-load fires on inserted content.

Links act on mousedown rather than click, so navigation starts immediately. While a request is in flight, the active link or form disables its submit buttons. The library enables the buttons again when the response arrives, which prevents duplicate submissions. ah-confirm calls window.confirm before disabling the buttons.

Two attributes act on input events. ah-submit-on-type submits the enclosing form with its declared fields. ah-post-on-type posts the form to an alternate endpoint without submitting it, which autosaves data while editing. Both attributes debounce after the last keystroke. You configure the delay with ah-debounce-on-type, which defaults to 200 ms.

<input name="q" ah-submit-on-type ah-debounce-on-type="300" />

A form[ah-get] serializes string fields into the query string and skips File inputs. A search form can then send a plain GET request.

To update the address bar, asyncHTML.pushURL pushes a history entry. A popstate listener reloads the recorded URL when the user clicks the back button.

The request

Every request goes through the buildRequest function for consistent security settings (see the source). Two headers matter to the server. AH-Referer identifies the requesting page. AH-CSRF-Token sends the token for unsafe methods only, since GET and HEAD never mutate state.

Requests stay on the same origin. The configuration credentials: "same-origin" sends cookies only to same-origin URLs, and mode: "same-origin" blocks cross-origin requests.

The server knows it is talking to asyncHTML

The AH-Referer header serves two purposes. First, it identifies asyncHTML requests so the server can choose between a fragment and a full page:

// IsAjax reports whether the request is an asyncHTML request.
func IsAjax(r *http.Request) bool {
	return r.Header.Get("AH-Referer") != ""
}

Handlers that serve only fragments reject all other requests:

func (h *Handler) CreateForPerson(w http.ResponseWriter, r *http.Request) {
	if err := webutil.ValidateParams(r, personCreateParams...); err != nil {
		h.WriteError(w, 400, err.Error())
		return
	}
	if !webutil.IsAjax(r) {
		h.WriteError(w, 400, "ajax only")
		return
	}
	// ...
}

Second, it names the return page after a successful mutation, read directly from the request:

h.Redirect(w, r, r.Header.Get("AH-Referer"))

Redirects without the double GET

The server must choose how to redirect. A standard HTTP 303 See Other works for full-page forms by requiring a GET request, but it fails with fetch. The redirect options each fail in different ways:

asyncHTML uses a custom response header instead. The server returns status 200 with AH-Location, and the client navigates:

const location = resp.headers.get("AH-Location");
if (location) {
  window.location.href = location;
  return;
}

On the server, the redirect helper checks whether the request came from asyncHTML. It validates that the target URL is on the same origin before redirecting:

func redirect(w http.ResponseWriter, r *http.Request, location string) {
	if location == "" {
		location = "/"
	}
	if !webutil.SameOriginOrInternalPath(r, location) {
		w.WriteHeader(400)
		return
	}
	if !webutil.IsAjax(r) {
		http.Redirect(w, r, location, 303)
		return
	}
	w.Header().Set("AH-Location", webutil.AbsoluteURL(r, location))
	w.WriteHeader(200)
}

The same mechanism handles authentication failures. When CSRF validation rejects an async request, the middleware returns AH-Location pointing to the referring page. The browser reloads the full login page instead of inserting it into a fragment:

func (m CSRF) reject(w http.ResponseWriter, r *http.Request) {
	m.session.Clear(r)
	if location := ajaxReloadLocation(r); location != "" {
		w.Header().Set("AH-Location", location)
		w.WriteHeader(200)
		return
	}
	http.Redirect(w, r, "/login", 303)
}

ajaxReloadLocation validates the AH-Referer value against the origin before returning it, which prevents open redirects.

Running fragment scripts

The HTML5 specification prevents <script> tags from executing when set via innerHTML. asyncHTML creates a new <script> element for each tag and wraps the code in an IIFE. This isolates each script to its own scope. Using dynamic <script> elements instead of new Function or eval allows the Content Security Policy to omit 'unsafe-eval'.

Each fragment can provide its own behavior. For example, the note fragment above opens a drawer using APP.openDrawer and can focus the textarea.

Because fragments run scripts, the server must sanitize interpolated values. Always encode request data for JavaScript strings before inserting it into a template. Unescaped query parameters or headers allow reflected XSS attacks. Pass dynamic values through a server-side escape_javascript helper before they reach the fragment.

Multipart forms on the server

ah-post forms submit FormData, which fetch sends as multipart/form-data. Go's r.ParseForm ignores multipart bodies, and r.FormValue will not parse them afterward. The parser must check the content type:

func parsePostForm(r *http.Request) error {
	if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
		return r.ParseMultipartForm(maxMultipartMemory)
	}
	return r.ParseForm()
}

File uploads

ah-upload keeps large files off the application server. The input specifies the presigned URL, sets the field prefix with ah-name, and filters file types with accept:

%input{ type: "file", "ah-upload": "/uploads/presign", "ah-name": "attachment", accept: "image/png,image/jpeg" }

When the input changes, the client validates the file against accept. It requests a presigned URL from the server and uploads the file directly to object storage with PUT. The client then adds hidden inputs ([name], [type], [object_key]) so the form submission contains only metadata. The server reads these keys through a shared helper function:

func FileUpload(r *http.Request, name string) Upload {
	return Upload{
		Name:      strings.TrimSpace(r.FormValue(name + "[name]")),
		Type:      strings.TrimSpace(r.FormValue(name + "[type]")),
		ObjectKey: strings.TrimSpace(r.FormValue(name + "[object_key]")),
	}
}

Security

The server enforces security beyond the client checks. Middleware compares the AH-CSRF-Token header against the session token on state-changing requests:

func isSafeMethod(method string) bool {
	switch method {
	case "GET", "HEAD", "OPTIONS", "TRACE":
		return true
	default:
		return false
	}
}

Because CSRF tokens protect only unsafe methods, ah-get handlers must not modify state. Any state change available through GET is vulnerable to cross-site request attacks. Route every mutation through ah-post.

Handlers allowlist accepted parameters and reject unexpected input with status 400. This practice keeps the endpoint surface small. See go/web-framework for parameter validation and go/html-templates for template rendering.

This token design predates Sec-Fetch-Site. Go 1.25 provides http.NewCrossOriginProtection, which blocks cross-origin mutations using browser fetch metadata. This feature removes the need for CSRF tokens and meta tags. See Filippo Valsorda's writeup.

When to reach for it

asyncHTML suits server-rendered applications that need interactivity without a frontend framework. The server contract uses three headers (AH-Referer, AH-CSRF-Token, AH-Location) and a CSRF meta tag. The attribute set stays small because scripts travel directly with their HTML fragments. This approach provides the primary benefits of htmx with lower complexity.

Source

The whole library, asyncHTML.ts:

"use strict";

interface AsyncHTMLConfig {
  csrfHeader: string;
  refererHeader: string;
}

interface AsyncHTML {
  buildRequest: (url: string, options?: RequestInit) => Request;
  config: AsyncHTMLConfig;
  configure: (customConfig: Partial<AsyncHTMLConfig>) => void;
  confirm: (el: HTMLElement) => boolean;
  createHiddenInput: (name: string, value: string) => HTMLInputElement;
  disable: (element: HTMLFormElement | HTMLAnchorElement) => void;
  enable: (element: HTMLFormElement | HTMLElement) => void;
  fetch: (
    method: string,
    url: string,
    headers: Headers | undefined,
    body: string | FormData | null,
  ) => Promise<Response | undefined>;
  fetchAndRun: (
    method: string,
    url: string,
    body?: string | FormData | null,
  ) => Promise<void>;
  listen: (customConfig?: Partial<AsyncHTMLConfig>) => void;
  pushURL: (url: string) => void;
  toggle: (selector: string) => void;
  triggerGetOnLoad: (root?: ParentNode) => void;
}

declare global {
  interface Window {
    asyncHTML: AsyncHTML;
  }
}

export const asyncHTML: AsyncHTML = {
  buildRequest: (url: string, options?: RequestInit): Request => {
    const headers = new Headers(options?.headers || undefined);

    // only add CSRF header for non-idempotent HTTP methods
    const method = options?.method?.toUpperCase() || "GET";
    const nonIdempotentMethods = ["POST", "PUT", "PATCH", "DELETE"];

    if (nonIdempotentMethods.includes(method)) {
      const csrfToken =
        document.querySelector<HTMLMetaElement>("[name='csrf-token']")
          ?.content ?? "";
      headers.append(asyncHTML.config.csrfHeader, csrfToken);
    }

    // always add referer header
    headers.append(asyncHTML.config.refererHeader, window.location.href);

    const secureOptions: RequestInit = {
      ...options,
      headers: headers,
      credentials: "same-origin",
      mode: "same-origin",
    };

    return new Request(url, secureOptions);
  },

  config: {
    csrfHeader: "AH-CSRF-Token",
    refererHeader: "AH-Referer",
  },

  configure: (customConfig: Partial<typeof asyncHTML.config>) => {
    asyncHTML.config = { ...asyncHTML.config, ...customConfig };
  },

  confirm: (el: HTMLElement): boolean => {
    const txt = el.getAttribute("ah-confirm");
    if (txt === null) {
      return true;
    }

    return window.confirm(txt);
  },

  createHiddenInput: (name: string, value: string) => {
    const hidden = document.createElement("input");
    hidden.type = "hidden";
    hidden.name = name;
    hidden.value = value;
    return hidden;
  },

  disable: (element: HTMLFormElement | HTMLAnchorElement): void => {
    if (element.tagName === "FORM") {
      // It's a form, disable all relevant children
      const elements = element.querySelectorAll("button, input[type='submit']");
      elements.forEach((el: Element) => {
        (el as HTMLElement).setAttribute("disabled", "true");
        (el as HTMLElement).classList.add("disabled");
      });
    } else {
      // It's not a form, disable the element itself
      element.setAttribute("disabled", "true");
      element.classList.add("disabled");
    }
  },

  enable: (element: HTMLFormElement | HTMLElement): void => {
    if (element.tagName === "FORM") {
      // It's a form, enable all relevant children
      const elements = element.querySelectorAll("button, input[type='submit']");
      elements.forEach((el: Element) => {
        (el as HTMLElement).removeAttribute("disabled");
        (el as HTMLElement).classList.remove("disabled");
      });
    } else {
      // It's not a form, enable the element itself
      element.removeAttribute("disabled");
      element.classList.remove("disabled");
    }
  },

  fetch: async (
    method: string,
    url: string,
    headers: Headers | undefined,
    body: string | FormData | null = null,
  ) => {
    // build request
    const req = asyncHTML.buildRequest(url, {
      method,
      headers,
      body,
    });

    // fetch
    let resp;
    try {
      resp = await fetch(req);
    } catch (error) {
      return;
    }

    // handle redirect (200 with AH-Location header)
    const location = resp.headers.get("AH-Location");
    if (location) {
      window.location.href = location;
      return;
    }

    // handle error
    if (!resp.ok) {
      return;
    }

    return resp;
  },

  fetchAndRun: async (
    method: string,
    url: string,
    body: string | FormData | null = null,
  ) => {
    const headers = new Headers({
      Accept: "text/html",
    });

    const resp = await asyncHTML.fetch(method, url, headers, body);
    if (!resp) {
      return;
    }

    const html = await resp.text();
    if (html) {
      // set up temp container
      const tmp = document.createElement("div");
      document.body.appendChild(tmp);

      // inject HTML
      tmp.innerHTML = html;

      // Run inline scripts. Setting innerHTML does not execute embedded
      // <script> tags (HTML5 spec), so we re-emit each one as a fresh
      // <script> element. Wrapping in an IIFE preserves the function-
      // scope isolation a previous `new Function(...)` implementation
      // provided, and dropping `new Function` lets the CSP omit
      // 'unsafe-eval' .
      Array.from(tmp.querySelectorAll("script")).forEach((script) => {
        const replacement = document.createElement("script");
        replacement.text = `(function () {\n${script.text}\n})();`;
        document.head.appendChild(replacement);
        document.head.removeChild(replacement);
      });

      // remove temp container
      document.body.removeChild(tmp);
    }
  },

  // Fire [ah-get-on-load] requests for any matching elements under `root`.
  // Called both on initial page load (against document.body) and after any
  // ajax DOM replacement so freshly-inserted content's load hooks run too.
  triggerGetOnLoad: (root: ParentNode = document.body): void => {
    root.querySelectorAll("[ah-get-on-load]").forEach((element) => {
      const url = element.getAttribute("ah-get-on-load");
      if (!url) {
        return;
      }

      asyncHTML.fetchAndRun("GET", url);
    });
  },

  listen: () => {
    // a[ah-get]
    document.body.addEventListener("mousedown", async (event) => {
      const a = (event.target as HTMLElement).closest("a[ah-get]");
      if (!a || !(a instanceof HTMLAnchorElement)) {
        return;
      }
      event.preventDefault();

      const url = a.getAttribute("ah-get");
      if (!url) {
        return;
      }

      asyncHTML.disable(a);

      if (!asyncHTML.confirm(a)) {
        asyncHTML.enable(a);
        return;
      }

      await asyncHTML.fetchAndRun("GET", url);

      asyncHTML.enable(a);
    });

    // a[ah-post]
    document.body.addEventListener("mousedown", async (event) => {
      const a = (event.target as HTMLElement).closest("a[ah-post]");
      if (!a || !(a instanceof HTMLAnchorElement)) {
        return;
      }
      event.preventDefault();

      const url = a.getAttribute("ah-post");
      if (!url) {
        return;
      }

      asyncHTML.disable(a);

      if (!asyncHTML.confirm(a)) {
        asyncHTML.enable(a);
        return;
      }

      await asyncHTML.fetchAndRun("POST", url);

      asyncHTML.enable(a);
    });

    // form[ah-post]
    document.body.addEventListener("submit", async (event) => {
      const form = (event.target as HTMLElement).closest("form[ah-post]");
      if (!form || !(form instanceof HTMLFormElement)) {
        return;
      }
      event.preventDefault();

      const url = form.getAttribute("ah-post");
      if (!url) {
        return;
      }

      asyncHTML.disable(form);

      if (!asyncHTML.confirm(form)) {
        asyncHTML.enable(form);
        return;
      }

      const body = new FormData(form);

      await asyncHTML.fetchAndRun("POST", url, body);

      asyncHTML.enable(form);
    });

    // form[ah-get]
    document.body.addEventListener("submit", async (event) => {
      const form = (event.target as HTMLElement).closest("form[ah-get]");
      if (!form || !(form instanceof HTMLFormElement)) {
        return;
      }
      event.preventDefault();

      const baseUrl = form.getAttribute("ah-get");
      if (!baseUrl) {
        return;
      }

      asyncHTML.disable(form);

      if (!asyncHTML.confirm(form)) {
        asyncHTML.enable(form);
        return;
      }

      // Build query string from string values only; skip File entries,
      // which URLSearchParams would otherwise stringify to "[object File]".
      const searchParams = new URLSearchParams();
      for (const [key, value] of new FormData(form).entries()) {
        if (typeof value === "string") {
          searchParams.append(key, value);
        }
      }
      const params = searchParams.toString();
      const url = baseUrl + (baseUrl.includes("?") ? "&" : "?") + params;

      try {
        await asyncHTML.fetchAndRun("GET", url);
      } finally {
        asyncHTML.enable(form);
      }
    });

    // input[ah-submit-on-type] (debounced submit)
    const debounceTimers = new WeakMap<HTMLInputElement, number>();

    document.body.addEventListener("input", (event) => {
      const input = (event.target as HTMLElement).closest(
        "input[ah-submit-on-type]",
      ) as HTMLInputElement | null;
      if (!input) {
        return;
      }

      // Parse debounce interval; default to 200 ms
      const delay =
        parseInt(input.getAttribute("ah-debounce-on-type") || "", 10) || 200;

      // Reset any existing timer for this input
      const prev = debounceTimers.get(input);
      if (prev !== undefined) {
        clearTimeout(prev);
      }

      const timer = window.setTimeout(() => {
        debounceTimers.delete(input);

        const form = input.closest("form") as HTMLFormElement;
        if (!form) {
          return;
        }

        // Trigger a normal submit so the other listeners
        // (form[ah-post] / form[ah-get]) can do their work.
        if ("requestSubmit" in form) {
          (form as HTMLFormElement).requestSubmit();
        }
      }, delay);

      debounceTimers.set(input, timer);
    });

    // input[ah-post-on-type], textarea[ah-post-on-type] (debounced POST)
    const postOnTypeTimers = new WeakMap<HTMLElement, number>();

    document.body.addEventListener("input", (event) => {
      const input = (event.target as HTMLElement).closest<HTMLFormElement>(
        "input[ah-post-on-type], textarea[ah-post-on-type]",
      );
      if (!input) {
        return;
      }

      const url = input.getAttribute("ah-post-on-type");
      if (!url) {
        return;
      }

      const delay =
        parseInt(input.getAttribute("ah-debounce-on-type") || "", 10) || 200;

      const prev = postOnTypeTimers.get(input);
      if (prev !== undefined) {
        clearTimeout(prev);
      }

      const timer = window.setTimeout(async () => {
        postOnTypeTimers.delete(input);

        const form = input.closest("form") as HTMLFormElement;
        if (!form) {
          return;
        }

        const body = new FormData(form);
        await asyncHTML.fetchAndRun("POST", url, body);
      }, delay);

      postOnTypeTimers.set(input, timer);
    });

    // input[type="file"][ah-upload]
    document.body.addEventListener("change", async (event) => {
      const input = (event.target as HTMLElement).closest(
        "input[type='file'][ah-upload]",
      );
      if (
        !input
        || !(input instanceof HTMLInputElement)
        || !input.files
        || input.files.length === 0
      ) {
        return;
      }

      const form = input.closest("form");
      if (!form) {
        return;
      }

      const presignedUrl = input.getAttribute("ah-upload");
      if (!presignedUrl) {
        return;
      }

      const name = input.getAttribute("ah-name");
      if (!name) {
        return;
      }

      event.preventDefault();

      asyncHTML.disable(form);

      for (const file of input.files) {
        const acceptedFileTypes = input.accept
          .split(",")
          .map((type) => type.trim());
        if (!acceptedFileTypes.includes(file.type)) {
          return;
        }

        const headers = new Headers({
          Accept: "application/json",
          "Content-Type": "application/json",
        });
        const body = JSON.stringify({
          filename: file.name,
          filetype: file.type,
        });
        const resp = await asyncHTML.fetch("POST", presignedUrl, headers, body);
        if (!resp) {
          return;
        }
        const { url, key } = await resp.json();

        // Upload the file to S3 using the presigned URL
        await fetch(url, {
          method: "PUT",
          body: file,
          headers: { "Content-Type": file.type },
        });

        form.appendChild(
          asyncHTML.createHiddenInput(`${name}[name]`, file.name),
        );
        form.appendChild(
          asyncHTML.createHiddenInput(`${name}[type]`, file.type),
        );
        form.appendChild(
          asyncHTML.createHiddenInput(`${name}[object_key]`, key),
        );
      }

      asyncHTML.enable(form);
    });

    // [ah-toggle]
    document.body.addEventListener("click", (event) => {
      const el = (event.target as HTMLElement).closest("[ah-toggle]");
      if (!el) {
        return;
      }
      event.preventDefault();

      const selector = el.getAttribute("ah-toggle");
      if (!selector) {
        return;
      }

      asyncHTML.toggle(selector);
    });

    // pop URL off the browser history stack so the back button works
    // after asyncHTML.pushURL is used
    window.addEventListener("popstate", (event) => {
      if (event.state && event.state.ahURL) {
        window.location.href = event.state.ahURL;
      }
    });
  },

  pushURL: (url: string) => {
    const currentStateObject = { ahURL: location.href };
    history.replaceState(currentStateObject, "", location.href);

    const nextStateObject = { ahURL: url };
    history.pushState(nextStateObject, "", url);
  },

  toggle: (selector: string) => {
    if (!selector) {
      return;
    }

    const target = document.querySelector<HTMLElement>(selector);
    if (!target) {
      return;
    }

    target.classList.toggle("hidden");
  },
};

window.asyncHTML = asyncHTML;

← All articles