SheetLink Forms beta

Hugo forms to Google Sheets

A partial, a site param, and a plain form action. Your Hugo site stays fully static while every submission lands as a row in your sheet.

Hugo builds sites in milliseconds and ships them as pure static files - which is exactly why it has never had an answer for forms. There is no server in a Hugo deploy, so there is nothing to receive a POST. SheetLink Forms fills that gap without changing your architecture: you point a form's action at a permanent endpoint of the shape https://sheetlinkforms.com/f/{token}, and each submission lands as a row in Google Sheets or an Excel Online table. No serverless functions, no third-party JavaScript widget rendering your form for you, and nothing added to your build.

Delivery is direct: an asynchronous worker writes straight to the Google Sheets API or Microsoft Graph - no Zapier, no webhook-replaying middleman - with retries at 5 minutes, 30 minutes, and 2 hours. Every attempt shows in a delivery log in the dashboard, and a formula-injection guard escapes leading =, +, -, and @ characters so a hostile submission cannot become a live formula in your ledger. There are no per-submission fees, ever.

Because the integration is just HTML, it slots into Hugo's own idioms: the form lives in a partial, the token lives in your site config, and content authors can drop the form into any markdown page with a shortcode. You can see the receiving end working on the live demo. SheetLink Forms is free during beta and invite-gated - join the waitlist to get an endpoint.

The best path for Hugo

The best path is a plain form action wrapped in a Hugo partial. Put the token in hugo.toml under [params], read it in the partial with .Site.Params.sheetlinkToken, and include the partial wherever the form should appear. That keeps the token in one place, keeps the form markup in one file, and means switching endpoints later is a one-line config change followed by a rebuild. The endpoint accepts the standard urlencoded post (bodies capped at 256KB) and answers with a 303 redirect to a hosted thank-you page or a custom redirect URL - no JavaScript required, which suits a generator whose output is static by definition.

If you want AJAX submits and ad attribution, add the optional sl.js embed to your base template (layouts/_default/baseof.html) so it ships on every page. It upgrades the same form to an inline-success AJAX submit, auto-injects the honeypot, adds a timing signal used in spam screening, and captures utm_source, utm_medium, utm_campaign, utm_term, and utm_content plus the gclid, wbraid, gbraid, fbclid, and msclkid click IDs - persisted in localStorage so attribution survives a visitor browsing several pages before submitting.

<!-- layouts/partials/contact-form.html --> <!-- hugo.toml: [params] sheetlinkToken = "slf_yourtoken" --> <form action="https://sheetlinkforms.com/f/{{ .Site.Params.sheetlinkToken }}" method="POST"> <input type="text" name="Name" required> <input type="email" name="Email" required> <textarea name="Message"></textarea> <!-- Honeypot: keep it exactly as-is; visitors never see it --> <input name="_slhp" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px"> <button type="submit">Send</button> </form> <!-- Optional, in baseof.html before </body>: AJAX + ad attribution --> <script src="https://sheetlinkforms.com/sl.js" data-token="{{ .Site.Params.sheetlinkToken }}"></script>

1. Connect a sheet and get your endpoint

Sign in and connect Google Sheets with one-click OAuth. The connection uses the drive.file scope, meaning SheetLink Forms can only touch sheets you pick in the Google picker or create inside the product - the rest of your Drive stays out of reach. On Microsoft 365, connect Excel Online instead and pick an Excel table; the table's columns define the row shape. You get a permanent endpoint at https://sheetlinkforms.com/f/{token}.

2. Put the token in hugo.toml

Add it under [params] as sheetlinkToken = "slf_yourtoken". Site params are the Hugo-native home for values templates need everywhere; the token is safe to render into public HTML because the endpoint is designed to be public and screened by its own spam layers. If you ever rotate tokens or run separate forms for separate sites, the change stays in config, not scattered through templates.

3. Create the partial, honeypot included

Save the snippet above as layouts/partials/contact-form.html and render it in any template with {{ partial "contact-form.html" . }}. Name inputs after your column headers and mapping happens automatically - matching is case and punctuation insensitive, so Email, email, and E-mail all land in an Email column. Keep the _slhp honeypot input exactly as-is: it is the only signal that marks spam outright, while everything else suspicious is quarantined for one-click review, never silently dropped.

4. Expose it to markdown pages with a shortcode

Content authors write markdown, not templates, so give them a shortcode: create layouts/shortcodes/contact-form.html containing {{ partial "contact-form.html" . }}, and any content file can place the form with {{< contact-form >}}. The same partial now serves layouts and content alike, and there is exactly one copy of the form markup to maintain.

5. Optionally add sl.js, then test

Drop the script tag into baseof.html before </body>; it binds any form whose action points at /f/ and upgrades it to an AJAX submit with inline success text, plus UTM and click-ID capture. Then run hugo, deploy, and send a test submission. On an empty sheet the header row is seeded for you; after that, rows append under the header. Watch the attempt in the delivery log, and see the full pipeline on how it works.

FAQ

Does this add anything to my Hugo build?

No. The integration is plain HTML in a partial - no modules, no theme components, no build-time dependencies, and no change to your build time. The optional sl.js embed is a single script tag served from sheetlinkforms.com.

Can I put the form inside a markdown content page?

Yes, through a shortcode. Hugo does not render raw HTML in markdown by default, so wrap the partial in layouts/shortcodes/contact-form.html and authors can place {{< contact-form >}} in any content file.

Does it matter where the site is hosted?

No - GitHub Pages, Netlify, Cloudflare Pages, S3, your own server. The form posts from the visitor's browser to the SheetLink Forms endpoint, so the host never has to run code. If you are on GitHub Pages specifically, the GitHub Pages guide covers that setup end to end.

How is spam handled with no backend of my own?

On the endpoint itself: the _slhp honeypot, per-form and per-IP rate limits, an optional per-form origin allowlist, content heuristics with a per-form strictness dial (loose/normal/strict), the sl.js timing signal, and optional Cloudflare Turnstile. Suspicious submissions are quarantined for one-click review - approving one delivers the row. Details are in the docs.

What does it cost?

Free during beta, invite required - join the waitlist. Planned post-beta pricing is a free tier around 50 submissions a month and a paid plan around $15-19/mo, unmetered. No per-submission or per-task fees, ever.

Also on: Jekyll · 11ty · GitHub Pages

Request an invite See the live demo