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:
a[ah-get],a[ah-post]: request on clickform[ah-get],form[ah-post]: request on submitinput[ah-submit-on-type]: debounced submit of the enclosing forminput[ah-post-on-type],textarea[ah-post-on-type]: debounced POSTah-debounce-on-type: debounce interval in ms for either of the aboveinput[type=file][ah-upload]: presign and upload to object storageah-name: prefix for the hidden inputsah-uploadwrites back[ah-get-on-load]: fire a GET when the element enters the DOM[ah-toggle]: toggle a.hiddenclass on a selector[ah-confirm]:window.confirmbefore acting
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:
redirect: "follow"(the default) follows the 303 to aGET, then JS seesresp.redirectedand navigates toresp.url, turning one POST into POST then GET then GETredirect: "error"rejects with a network error and loses the targetredirect: "manual"returns an opaque response whoseLocationheader CORS will not let you read
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;