← Blog

No More WordPress: AI-Driven Development for Agency Websites

I relied on WordPress and Beaver Builder for my own and client marketing sites for years. They were dependable, easy to set up, easy to update, and reasonably customizable for page builders. They did the job.

However, as AI entered the programming scene and models became better at design work, it became clear to me that page builders were no longer the future of how I wanted to build websites.

With coding agents, static sites can be built and deployed quickly—with the initial content in place. Changes and refinements still need to be made, but the speed of iteration is impossible to match by clicking around in Beaver Builder, Webflow, Wix, or Squarespace.

I build a number of client websites through Winnipeg Local. Most of them are for local service businesses that need local search visibility, good content, a responsive design, and a reliable way for someone to get in touch.

They generally do not need a full Rails application or even WordPress for that matter.

Hugo has turned out to be a really good fit for this work. It gives me static HTML, very fast builds, and almost no production application to operate. I can keep the content, templates, styles, configuration, and redirects together in Git and deploy the generated site to Cloudflare Pages.

The only thing this doesn’t make easy out of the box is contact forms.

So I built a shared form-submission application for the static sites. My requirements were:

I call it Quantum Fire HQ and it provides a home for managing client websites, deployments (production, staging), forms, form submissions, email notifications for form submissions, and third-party API integrations.

The setup now has two main pieces:

  1. A custom Hugo site that owns the public website and ordinary HTML forms.
  2. Quantum Fire HQ, a Rails application that handles the work that cannot happen in a static build.

Hugo still owns the website

Quantum Fire HQ is not a CMS and it does not render the client website. Hugo remains responsible for the content, page structure, metadata, structured data, CSS, JavaScript, images, and the final static output.

Each client site is its own repository with custom layouts and styling. I am not pushing every client into one shared theme. A roofing company, chiropractor, hair salon, and business directory do not need to look like variations of the same template.

This setup also works well with the way I use coding agents. When I need to make a change, I prompt the agent working in that repository or assign the work through my project management platform.

What I reuse is the project structure and the boring operational pieces.

Most sites split things between Markdown under content, structured business data under data, templates and partials under layouts, pipeline-managed files under assets, and files that should pass through unchanged under static. Headers, footers, forms, metadata, and JSON-LD are partials, but they still belong to the individual site.

There is also generally no package.json. Hugo Pipes handles the parts I would otherwise need a separate frontend build tool for. CSS and JavaScript can be bundled, minified, fingerprinted, and served with integrity hashes directly from the Hugo build. On sites that need it, Hugo also creates resized WebP and JPEG image variants.

I like having that available without turning a small business website into a JavaScript project.

Development, preview, and production configuration

The newer sites use Hugo’s configuration-directory setup:

config/
├── _default/hugo.toml
├── development/hugo.toml
├── preview/hugo.toml
└── production/hugo.toml

Not every site needs all four environments, but the split gives me a consistent place for anything that changes between local development and a public deployment.

The default config declares that the site has an HQ integration. Development points forms at the Rails app running on my computer:

[params.hq]
  base_url = "http://localhost:3000"

Production points at the hosted HQ service and includes the public Turnstile site key for that website. The secret key stays encrypted inside HQ and is never part of the Hugo repository.

A preview environment can use the hosted form service with a registered preview domain while leaving analytics and production-only behaviour disabled. Cloudflare Pages middleware also keeps preview hostnames out of search indexes. This lets me test the actual cross-origin request path before changing the production site.

The important part is that form partials do not need to know which environment they are in. They read site.Params.hq.base_url, and Hugo supplies the right value for the current build.

Forms remain ordinary HTML

I didn’t want a contact form that depended on application-specific JavaScript before the browser could submit it. The base integration is still a normal HTML form:

<form
  action="{{ site.Params.hq.base_url }}/forms/contact/submissions"
  method="POST"
  data-hq-form="contact">
  <!-- Site-specific fields -->
  {{ partial "forms/_honeypot.html" . }}
  <button type="submit">Send</button>
</form>

The form slug in the URL identifies the form inside HQ. A site can have contact, estimate, appointment, or another form without needing a different controller or public endpoint for each one.

Every form includes the same visually hidden hp_ honeypot field. Production forms also render Cloudflare Turnstile when the current environment has a site key configured.

The form does not depend on hq-forms.js. If that enhancement script is unavailable, the browser submits directly to HQ and follows a 303 redirect to the configured success location or back to the referring page.

Production forms still depend on Turnstile’s JavaScript to generate a valid token. They are not fully functional in a browser with all JavaScript disabled. The progressive part is my form handler, not Turnstile.

The small JavaScript layer

Each form-enabled site includes the same plain JavaScript handler, hq-forms.js. It listens for submissions from forms marked with data-hq-form, sends the browser’s FormData to the same form action, and asks HQ for JSON.

A successful response can redirect to another page or replace the form with an inline success panel. Validation failures are shown inside the form. A rate-limited request gets a useful message instead of a generic error page. If the request fails entirely, the visitor can try again without losing the page.

There is no frontend framework involved. It is one small script progressively enhancing behaviour the browser already knows how to perform.

The form markup and success state remain site-specific. A roofing estimate form does not need the same fields or message as a salon contact form. The submission plumbing is the shared part.

Quantum Fire HQ handles the non-static part

Quantum Fire HQ is a Rails application. It provides the authenticated agency portal, the public form endpoint, submission storage, notification delivery, and website-scoped access for clients and external systems.

The main records are websites and deployments. A website has one production deployment and can have additional staging or development domains. When a form submission arrives, HQ reads the request’s Origin, falling back to Referer, normalizes the hostname, and looks for a registered deployment.

That is how HQ determines which website sent the form. The website does not put an account ID or secret into the form action.

HQ currently runs on Rails with SQLite, Solid Queue, Solid Cache, and Postmark. Notification emails are queued after the submission commits. The application is containerized and deployed with Kamal. It is a fairly conventional Rails application, which is exactly what I want for the stateful half of this setup.

YAML schemas are the contract

The Hugo form and HQ agree on a form slug and a set of field names. The server-side contract lives in a YAML file under the production domain:

config/form_schemas/example.com/contact.yml

A basic schema looks like this:

name: Contact
status: active
fields:
  - { name: name,    label: Name,    type: text,     required: true }
  - { name: email,   label: Email,   type: email,    required: true }
  - { name: company, label: Company, type: text,     required: false }
  - { name: message, label: Message, type: textarea, required: true }

Schemas support text, email, phone, textarea, select, checkbox, and checkbox-group fields. They define required values and the allowed options for fields where that matters.

Only fields declared in the schema are retained. Strings are trimmed, checkbox values are cast, checkbox groups are normalized, and undeclared parameters are discarded. The saved submission is validated against the same schema again at the model layer.

The schema also gives HQ the labels it needs to display a useful submission and format the notification email. I don’t have to recreate the presentation rules for every form inside the Rails views.

I intentionally keep these schemas in the HQ repository instead of making them editable through the customer portal. A schema change is an application change that needs to stay aligned with the deployed Hugo form. Keeping both sides in Git makes that difference visible.

What happens to a submission

The request flow is:

  1. Match the Origin or Referer hostname to a registered deployment.
  2. Load the active form schema using the website’s production domain and form slug.
  3. Silently accept honeypot submissions without storing them.
  4. Apply a rate limit scoped to the deployment, form, and IP address.
  5. Verify the Cloudflare Turnstile token when Turnstile is configured.
  6. Cast and retain only schema-declared values.
  7. Validate and save the submission with its deployment and request metadata.
  8. Queue notification emails after the database commit.
  9. Return JSON to the enhanced form or a redirect to the regular HTML form.

Production submissions notify the agency and the customer users who have access to that website. Development and staging submissions remain visible to internal users without bothering the client while I test.

Postmark delivers the email, and its webhooks update the sent-email record with delivery, bounce, open, or complaint activity. The submission remains available in HQ even if somebody loses or deletes the notification email.

Clients sign into the same application and are assigned access to their websites. The normal customer-facing views show production submission data, while internal users can inspect non-production deployments when testing a form.

HQ also has a website-scoped API for cases where a client’s CRM needs to poll submissions. API consumers receive a revocable credential for one website rather than access to everything in the agency account.

Spam and request boundaries

A public form endpoint is still a public form endpoint. The goal is to make casual abuse expensive and prevent one client site’s integration from accidentally writing into another client’s records.

The current layers are:

Origin matching is useful for browsers, but it is not cryptographic authentication. A custom HTTP client can forge an Origin header. Turnstile and rate limiting are the stronger anti-automation boundaries, and none of these make a public endpoint impossible to attack.

The application only accepts configured form slugs and declared fields. There are no arbitrary database columns, mail recipients, or redirect targets supplied by the public request.

Adding a new client site

Most of the setup is mechanical now:

  1. Build the form as a Hugo partial using the site’s actual design and content.
  2. Add the shared honeypot partial and hq-forms.js handler.
  3. Add development and production HQ configuration.
  4. Register the website and its deployment domains in HQ.
  5. Add the matching YAML schema to the HQ repository.
  6. Configure a Turnstile widget and store its secret in HQ.
  7. Deploy HQ and the Hugo site.
  8. Test the regular HTML submission, the enhanced submission, and Turnstile in each applicable environment.

The schema and Hugo markup still need to be reviewed together. If a required field is renamed on one side and not the other, HQ rejects the submission rather than quietly storing an incomplete lead.

This does mean a new form can require changes in two repositories. That is the main tradeoff, but it is a fair one considering the benefits of validating the form schema in HQ.

Where it is now

The public sites remain custom, static Hugo projects deployed at the edge. They can have completely different page structures and visual systems while sharing the same approach to environments, previews, asset processing, forms, and operational handoff.

Quantum Fire HQ provides the stateful part: registered deployments, schema-validated submissions, spam controls, Postmark notifications, delivery tracking, customer access, and optional downstream API access.

The client website stays a normal Hugo site. HQ handles the operational work that does not belong in a static build.