Quick answer: Put working forms in Next.js client components, Astro islands, or Hugo pages—one hosted endpoint stores every submission. No API routes, no server.
The modern frontend went static again. Next.js renders on the edge, Astro ships zero JavaScript unless you ask for it, Hugo compiles a folder of Markdown into plain HTML. Fast, cacheable, cheap to host—and then you remember the one thing every real site eventually needs: a form. The moment a visitor has to send you something, the question comes back with a vengeance: where does that data go?
The reflex answer is "add an API route." But on a static-rendered site that answer quietly undoes the whole architecture—you suddenly own a serverless function, its cold starts, its environment variables, and whatever database is behind it. There is a lighter pattern: the page stays exactly as static as it was, and the form POSTs to a hosted endpoint that stores every submission for you.
One endpoint, three submit shapes
Publish any FormLM form and you get an HTTP endpoint. It accepts the same data in whichever shape your stack prefers:
- JSON via fetch — the fit for React client components, Vue, or any island that is already hydrated. POST
{"data": {...}}and move on. - A native form POST — plain
action+method="POST"on the/formpath. Zero JavaScript, so it works in Astro static markup and Hugo output exactly as written. - Self-documenting schema — append
?helpto the endpoint and it returns its own Markdown field documentation, which is also how you hand the integration to an AI coding agent.
CORS is enabled on both reads and writes, so the browser sends straight to the endpoint from any origin—even from a file:// page you are testing locally. No proxy, no same-origin gymnastics.
Next.js: the client component skips the API route
In the App Router, a form that talks to a first-party API route runs through the server. When the "backend" is just "store this object," the route is pure overhead. Make the component a client component and POST directly to the endpoint:
'use client';
import { useState } from 'react';
type Feedback = { name: string; rating: number; note?: string };
const ENDPOINT = 'https://formlm.me/api/v3/share/<your-token>'; // published form's Data API URL
export default function FeedbackForm() {
const [done, setDone] = useState(false);
async function submit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const f = new FormData(e.currentTarget);
const data: Feedback = {
name: String(f.get('name')),
rating: Number(f.get('rating')),
note: f.get('note') ? String(f.get('note')) : undefined,
};
await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
setDone(true);
}
if (done) return <p>Thanks — your feedback is in.</p>;
return (
<form onSubmit={submit}>
<input name="name" required />
<input name="rating" type="number" min={1} max={5} required />
<textarea name="note" />
<button>Send</button>
</form>
);
}
The Feedback type is your whole contract with the endpoint. Nothing about this page depends on server runtime: it still prerenders, it still deploys to any static host, and the interaction happens entirely in the browser. If you do keep a server component, the same POST works from a server action too—the difference is just where the fetch line lives.
Astro islands: hydrate nothing, POST natively
Astro ships HTML by default, which means the cheapest form in your project is also a plain HTML form. Point the action at the /form path and every field name becomes a key in the stored record:
<form action="https://formlm.me/api/v3/share/<your-token>/form" method="POST" autocomplete="off"> <label>Name <input name="name" required /></label> <label>Email <input name="email" type="email" required /></label> <textarea name="message" required></textarea> <input type="hidden" name="page" value="/contact" /> <!-- honeypot: humans never fill it --> <input name="website" style="display:none" tabindex="-1" /> <button type="submit">Send</button> </form>
Want a success message without a page reload? Promote just the form to an island (client:visible on a tiny React or Svelte wrapper) and switch to the fetch pattern above. The rest of the page keeps its zero-JS budget. That is the island architecture working as intended: hydration is spent where the interaction is, not across the document.
Hugo and every no-JS static site
Hugo, Eleventy, Gatsby-with-prerendering, plain hand-written HTML—the same native form POST is the universal answer, because it requires nothing the browser does not already do. Generate the page once, and the form keeps working forever without a build step in the loop. Dropping it into a layouts/partials/form.html is the entire integration.
Practical tip: keep the honeypot input in every form. Bots fill hidden fields; people never type into a field they cannot see. Because unknown keys auto-create their own columns, the extra website field costs you nothing—and a stored record with a value in it is a spam submission you can spot at a glance and filter at read time.
Let the schema evolve with the page
Static sites iterate: you add a company field to the contact form next month, or a rating scale to the feedback widget. With a FormLM endpoint, "changing the backend" is not a task. Submit a payload with a key the endpoint has never seen and that key becomes a new column on the first submission. Three habits make this safe as the site grows:
- Type your payload, ship every key optional — TypeScript types are for the author; the endpoint accepts whatever arrives.
- Read defensively — old records simply lack the new column. Treat missing keys as nulls in dashboards and scripts.
- Re-check the schema with
?help— the endpoint returns its current field list as Markdown, so you (or your agent) never code against a stale schema.
Success states, without the redirect
Native form POSTs leave the browser at the endpoint response—an acceptable tradeoff for pages that must run JS-free. Everywhere else, keep the visitor on the page: await the fetch, swap the form for a thank-you state, and offer the next action while attention is highest. On a static site, that little moment of feedback is the only part of the "backend experience" your users ever see—spend the two lines on it.
"The backend question never really went away when sites went static—it just got smaller. And small is the point: a URL that stores submissions is a much better architectural deal than a function, a queue, and a database, for the enormous category of sites whose entire server-side requirement is a form."
🛠️ Wire the endpoint into your framework
The developer zone keeps the live details as your form grows:
- One URL per published form — JSON, native HTML POST, and self-docs at
?help - Auto-created fields — new payload keys become new columns, no migration step
- Data UI + CSV export — for the humans, while the query API serves the scripts
- CORS open on reads and writes — from localhost, from disk, from production
✅ Key Takeaways
- Static-rendered stacks still need form storage—the endpoint pattern keeps the page as static as it was
- Next.js: a client component posting JSON skips the API route entirely; the page still prerenders
- Astro and Hugo: a native form POST to /form needs zero JavaScript and zero build ceremony
- A honeypot field costs nothing (unknown keys auto-create columns) and makes spam visible at read time
- Schema drift is a feature: add fields to the payload, and the endpoint learns them on the first submission
- Keep visitors on the page—await the POST, then swap the form for a thank-you state
Running assessments for clients rather than shipping features? The Consultant and Training Effectiveness tracks build the same instruments without a line of code.
Frequently Asked Questions
Can FormLM be the backend for my React or Next.js app?
Yes—from a client component, POST JSON like {"data": {...}} straight to the published form endpoint. CORS is enabled, so no API route or proxy is needed, and the page still prerenders and deploys to any static host.
Does this work on a fully static site with no JavaScript?
Yes. Point a plain HTML form action at the endpoint /form path and submit natively. Field names become record keys. This is the standard pattern for Hugo, Eleventy, and Astro pages that ship zero client JS.
What stops the endpoint from being spammed?
A hidden honeypot input is the cheapest guard: bots fill it, people never do. Because unknown keys auto-create columns, the honeypot field is stored with the record and spam is trivial to spot and filter in the data view or the query results.
How do I add fields without redeploying a backend?
Just ship the new key in the payload. Auto-create fields turn any unseen key into a new column on its first submission, and appending ?help to the endpoint returns its current Markdown field list so your code—human or agent—never reads a stale schema.
