You Don't Need a Backend: Building Contact Forms, Comments, and Auth on Static Sites
Every time you pitch a static site to another developer, the same objection comes back within ninety seconds.
“But what about the contact form?”
It’s the trump card. The conversation-ender. As if the existence of a form that sends email somehow requires a PHP server, a database, and a VPS you have to patch every month. As if it’s still 2014 and the only way to handle a POST request is to write a mail() call in a WordPress template.
It’s not. It hasn’t been for years. And the contact form is just the beginning — comments, authentication, search, payments, and newsletter signups all have clean solutions that require zero traditional backend infrastructure.
Here’s every “you need a server for that” objection, and the code that proves it wrong.
Contact Forms: The Oldest Objection
This is the one everyone reaches for first. A client needs a contact form. You need to receive the submission somewhere. Therefore you need a backend.
No. You need an endpoint that accepts POST data. That’s a different thing.
Option 1: Formspree (Zero Code)
Formspree gives you a form endpoint. Point your HTML action at it and you’re done.
<form action="https://formspree.io/f/your-form-id" method="POST">
<input type="text" name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send</button>
</form>
No JavaScript. No build step. No API keys exposed in client code. Submissions land in your inbox or get forwarded to whatever integration you configure — Slack, Notion, a Google Sheet. The free tier handles 50 submissions per month, which covers most small business sites comfortably.
You can add a honeypot field for spam filtering, redirect to a custom thank-you page, and attach file uploads. The point is that the form works with plain HTML and a network request. The “backend” is someone else’s API endpoint — and that’s all a contact form ever needed.
Option 2: Resend With an Edge Function
When you need more control — custom email templates, conditional routing, writing to a CRM, or validating a reCAPTCHA token before accepting the submission — pair Resend with a small edge function. This is a Next.js Route Handler that runs as a Vercel Edge Function, but the pattern works on Cloudflare Workers or Netlify Functions with minimal changes:
// app/api/contact/route.ts
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(request: Request) {
const { name, email, message } = await request.json();
if (!name || !email || !message) {
return Response.json({ error: "All fields required" }, { status: 400 });
}
await resend.emails.send({
from: "site@yourdomain.com",
to: "hello@yourdomain.com",
replyTo: email,
subject: `Contact form: ${name}`,
html: `<p><strong>${name}</strong> (${email})</p><p>${message}</p>`,
});
return Response.json({ success: true });
}
Option 3: Netlify Forms (If You’re Already on Netlify)
Add a netlify attribute to your form and Netlify detects it at build time. No function needed.
<form name="contact" method="POST" data-netlify="true">
<input type="text" name="name" required />
<input type="email" name="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
Submissions show up in your Netlify dashboard. You can add email notifications, Zapier integrations, or webhook triggers from there. One hundred submissions per month on the free tier. Spam filtering is built in.
The beauty of the Netlify approach is that there’s no separate function to deploy and no external service to configure. The form handling is detected from the HTML at deploy time and wired up automatically. It’s the closest thing to a zero-config solution that exists.
Three approaches. Three levels of control. None of them require a backend you build or maintain.
Comments: You Don’t Need a Database
The “comments need a server” assumption comes from WordPress, where comments are rows in the wp_comments table served by the same PHP process that renders the page. That model works but it brings moderation headaches, spam management, and database overhead.
Static sites have better options.
Giscus (GitHub Discussions-Backed Comments)
Giscus uses GitHub Discussions as its data store. Each page maps to a discussion thread. Users authenticate with GitHub to comment. It’s free, open-source, supports reactions, and renders as a lightweight embed.
<script
src="https://giscus.app/client.js"
data-repo="your-org/your-repo"
data-repo-id="R_your_repo_id"
data-category="Blog Comments"
data-category-id="DIC_your_category_id"
data-mapping="pathname"
data-strict="0"
data-reactions-enabled="1"
data-emit-metadata="0"
data-input-position="top"
data-theme="dark"
data-lang="en"
crossorigin="anonymous"
async
></script>
The setup takes five minutes through the Giscus configuration tool. You pick your repo, choose a mapping strategy (pathname is usually the right one), and copy the script tag. Themes are customizable, and there’s a React component if you want tighter integration:
import Giscus from "@giscus/react";
export function Comments() {
return (
<Giscus
repo="your-org/your-repo"
repoId="R_your_repo_id"
category="Blog Comments"
categoryId="DIC_your_category_id"
mapping="pathname"
reactionsEnabled="1"
theme="dark"
/>
);
}
The obvious tradeoff: your commenters need a GitHub account. For a developer-facing blog or technical documentation site, that’s a feature — it eliminates spam entirely and guarantees a baseline quality of engagement. For a bakery website, it’s a dealbreaker.
For Non-Technical Audiences
If your audience doesn’t have GitHub accounts, look at services like Hyvor Talk or Commento. Both provide embeddable comment widgets with their own authentication, moderation dashboards, and spam filtering. Hyvor Talk starts at $9/month. Commento has a self-hosted option if you want the widget without the subscription.
The point isn’t which service you pick. It’s that the comment data lives in a specialized system designed for comments, not in a general-purpose database bolted to your CMS.
Authentication: The One That Actually Scares People
Forms and comments are easy wins. Authentication is where developers get genuinely nervous about static sites. Login flows, protected routes, session management, OAuth — this is server territory, right?
It was. Now it’s a provider component and a couple of imports.
The shift happened because authentication is genuinely hard to build well. Password hashing, token rotation, CSRF protection, rate limiting on login endpoints, account recovery flows — doing this correctly from scratch is a multi-week project even for experienced backend developers. The same way you wouldn’t build your own payment processor, you probably shouldn’t build your own auth system. The services that handle it are better at it than you’ll be, and they’ve already dealt with the edge cases you haven’t thought of yet.
Clerk
Clerk provides drop-in authentication components for React, Next.js, and other frameworks. User management, session handling, OAuth providers, multi-factor auth — all managed by Clerk’s infrastructure. You add the provider and use their components:
// app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>{children}</body>
</html>
</ClerkProvider>
);
}
// app/dashboard/page.tsx
import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/nextjs";
export default function Dashboard() {
return (
<>
<SignedIn>
<h1>Welcome to your dashboard</h1>
{/* Protected content */}
</SignedIn>
<SignedOut>
<RedirectToSignIn />
</SignedOut>
</>
);
}
Clerk handles the user database, password hashing, session tokens, and OAuth flows. Your static site renders the UI. The authentication state lives in the client and in Clerk’s API, not in a server you manage.
Supabase Auth
If you want auth as part of a broader backend-as-a-service setup — where you might also need a database or storage later — Supabase Auth is the move. The auth layer is free for up to 50,000 monthly active users, and it supports email/password, magic links, and social OAuth out of the box.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
// Sign up
await supabase.auth.signUp({
email: "user@example.com",
password: "securepassword",
});
// Sign in
await supabase.auth.signInWithPassword({
email: "user@example.com",
password: "securepassword",
});
// Get current user
const { data: { user } } = await supabase.auth.getUser();
Neither of these is a hack or a workaround. They’re production auth systems used by thousands of apps. The user data just lives somewhere other than a server you maintain.
One thing to be clear about: this is client-side authentication gating, not server-side access control. If your page contains truly sensitive data that should never reach the client without verification, you need middleware or an edge function that checks the session before returning the response. Both Clerk and Supabase provide middleware helpers for this. But for most use cases — gated content, user dashboards, personalized UI — client-side auth checks are fine.
Search: Faster Than Your Database Query
“Users need to search the site” is presented as if full-text search requires Elasticsearch and a backend API. For most static sites, it requires a build step.
Pagefind (Static Search, Zero Backend)
Pagefind indexes your built HTML at build time and produces a static search index that ships with your site. Search queries run entirely in the browser against that index. No server. No API calls. Sub-10ms results.
npx pagefind --site out
That’s the build command. It scans your output directory, creates an index, and gives you a search UI you can embed:
<link href="/pagefind/pagefind-ui.css" rel="stylesheet" />
<script src="/pagefind/pagefind-ui.js"></script>
<div id="search"></div>
<script>
window.addEventListener("DOMContentLoaded", () => {
new PagefindUI({ element: "#search", showSubResults: true });
});
</script>
The entire search index for a 200-page site is typically under 100KB. It loads on demand when the user starts typing. There’s no ongoing cost, no rate limits, and no third-party dependency at runtime.
This is genuinely one of the best tools in the static site ecosystem. The search results are fast, relevant, and include content previews. You can customize which elements get indexed using data-pagefind-body and data-pagefind-ignore attributes, filter results by metadata, and style the UI to match your site. It’s what search on small-to-medium sites should have always been — and the fact that it works with zero infrastructure is almost unfair.
Algolia (When You Need More)
For sites with thousands of pages, faceted filtering, or typo tolerance that goes beyond what static search provides, Algolia is the standard. You push your content to their index at build time and query it from the client:
import algoliasearch from "algoliasearch/lite";
const client = algoliasearch("YOUR_APP_ID", "YOUR_SEARCH_ONLY_KEY");
const index = client.initIndex("posts");
const { hits } = await index.search("static site forms");
Algolia’s free tier covers 10,000 search requests per month. The index lives on their infrastructure. Your site stays static.
Payments: Stripe Without a Server
This one surprises people the most. You can accept payments on a static site. Stripe Checkout handles the entire payment flow — product selection, card entry, 3D Secure, receipts — on Stripe’s hosted page. You redirect the user there. Stripe handles everything.
The simplest version is a direct link. Create a Payment Link in the Stripe dashboard, and put it on your page:
<a href="https://buy.stripe.com/your-payment-link" class="buy-button">
Purchase — $49
</a>
That’s a payment flow. On a static site. With zero backend code.
For more control — dynamic prices, quantity selection, metadata — use Stripe Checkout with a small edge function:
// app/api/checkout/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(request: Request) {
const { priceId } = await request.json();
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${request.headers.get("origin")}/success`,
cancel_url: `${request.headers.get("origin")}/cancel`,
});
return Response.json({ url: session.url });
}
The payment page is hosted by Stripe. PCI compliance is Stripe’s problem. Your static site just redirects to the checkout URL and handles the return.
You can also listen for webhooks — payment succeeded, subscription canceled, refund issued — by adding another edge function that receives Stripe’s POST payload and triggers whatever downstream action you need: sending a confirmation email, updating a database row in Supabase, or granting access to gated content.
This is how most small e-commerce operations should work — and a shocking number of them are running an entire WooCommerce installation with a MySQL database, a PHP runtime, and an SSL certificate renewal pipeline to accomplish the same thing a Payment Link does in one line of HTML.
Newsletter Signups: A Form That Hits an API
Newsletter signups are the lowest-stakes version of the “you need a backend” argument, and the easiest to demolish. Every email platform provides an API or an embeddable form.
ConvertKit (Inline Form)
<form
action="https://app.convertkit.com/forms/your-form-id/subscriptions"
method="POST"
>
<input type="email" name="email_address" placeholder="Your email" required />
<button type="submit">Subscribe</button>
</form>
Buttondown (API Call From Client)
async function subscribe(email: string) {
const res = await fetch("https://api.buttondown.com/v1/subscribers", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Token ${process.env.NEXT_PUBLIC_BUTTONDOWN_API_KEY}`,
},
body: JSON.stringify({ email_address: email }),
});
return res.ok;
}
Both of these are fire-and-forget. The subscriber data lives in ConvertKit or Buttondown. Your site doesn’t store anything. You don’t need a backend to put an email address in a list.
If you want double opt-in, both platforms handle the confirmation email workflow themselves. You submit the address, they send the confirmation, they track the subscription status. The entire subscriber lifecycle is managed by the email platform — which is exactly where it should be managed, because that’s also where you’ll write and send the newsletters.
The Pattern
Look at every solution above. The pattern is the same:
- The static site handles presentation and client-side logic.
- A specialized third-party service handles the data operation.
- If custom server logic is needed, a tiny edge function bridges the gap.
That’s it. There’s no monolith. There’s no Express server. There’s no database you have to back up. Each concern is handled by a service that does that one thing well and charges you based on usage that, for most client sites, falls comfortably within a free tier.
The developer who says “but the client needs a contact form, so we need WordPress” is solving a problem that was solved years ago. The developer who says “but the client needs auth, so we need a full-stack framework with a server” is overbuilding by an order of magnitude.
And the cost story is compelling. For a typical small business client site — contact form, maybe a newsletter signup, maybe basic search — you’re looking at $0/month in service fees because everything falls within free tiers. Even a more complex site with auth, payments, and comments is usually under $30/month in third-party costs. Compare that to the hosting, maintenance, plugin licensing, and security patching of a WordPress or full-stack equivalent.
When You Actually Need a Backend
Honesty matters, so here’s where this approach breaks down:
Complex relational data with server-side business logic. If your app needs to enforce complex validation rules, run transactions across multiple tables, and maintain referential integrity on every write, you need a real backend with a real database. A collection of edge functions and third-party services won’t give you that.
Real-time multi-user collaboration. Google Docs-style concurrent editing, live dashboards with push updates from hundreds of sources, multiplayer game state — these require persistent connections and server-side state management.
Heavy server-side computation. Image processing pipelines, ML inference, batch data transformations — these need compute resources, not edge functions with 10ms execution limits.
If your project genuinely falls into one of these categories, build the backend. Use Rails, Django, Express, whatever fits. Nobody is arguing that backends shouldn’t exist — only that most sites don’t need one, and the features that seem to require one usually don’t.
But a marketing site with a contact form? A blog with comments? A small business site that takes bookings? A documentation site with search and auth?
None of those are backends. Those are features. And every one of them has a solution that keeps your site static.
The objection was valid once. It isn’t anymore. Build the static site. Add the features. Ship it.
Static Signal is published by Neuron Web Development.