··9 mins
Writing a simple Go API to easily integrate a form into a static site
Context #
My old website was built with plain old HTML, Bootstrap and JavaScript. The only time the server had to get active was on a contact form submission which was powered by PHPMailer. I like to keep things simple—always ship small payloads and avoid unnecessary requests to external services. Hugo is very fitting in that regard, as you can build your pages using a powerful templating engine and render it to well optimized frontend code without any further dependencies.
Alternatives #
Integrating a contact form into a Hugo page is not as straightforward as I thought. The easiest way would be to use one of countless form handling services. You build a plain HTML form and just set the POST endpoint to the external service where they process it for you. While very comfortable, I certainly do not like the implications of handing over data that contains the user’s name, email and possibly private content to some third party service hosted overseas.
Another way would be to bundle PHP components with hugo. There is a great
guide
on the Hugo forums, if you want to check that out. It involves configuring Hugo
to provide PHP files with the correct content type and serving PHP files from
the static folder. Your webserver would of course need to handle PHP too,
which also brings me to the reason I did not choose to go down this route: Hugo
is simple, I fire up my IDE and with a one-liner on the terminal I get a
webserver with hot reload and can type away. This is a much more comfortable
workflow than spinning up a webserver with PHP and serving the Hugo output I
would have to regenerate on each change. One more annoyance is the fact that I
would need to have composer or whatever package manager is hot today integrated
as I am not just writing plain PHP.
Solution #
Form Mailer API #
To keep things simple on the actual page stack itself, I decided to write a tiny API that would take form data, email me and return a success or error message that is suited to be rendered directly to the user. By leveraging Golang’s powerful standard library, this should be straightforward and done in no time. A versatile webserver, SMTP client and HTML templating engine can be put together easily without any external dependencies.
To combat spam, we use Friendly Captcha, a privacy oriented captcha service with a lenient free tier.
Here I just quickly want to go over the most important parts of what I built. Feel free to have a look around the source code if you want to dive deeper. As always, I am very grateful for suggestions and feedback.
OpenAPI Spec #
To start off, I wrote an OpenAPI spec defining a FormData and Response
object:
{
"name": "John Doe",
"email": "john@doe.com",
"subject": "Title",
"message": "Hello",
"frc-captcha-solution": "string"
}
On the client side, a captcha solution gets computed, transmitted with the form and validated on the backend.
{
"message": "Thanks for your message!",
"success": true
}
The endpoint is just as simple, just consuming x-www-form-urlencoded data.
/form:
post:
summary: Submit a form
description: Validates form and sends SMTP mail on success
requestBody:
description: Form body
content:
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/FormData"
required: true
2025-07-05 update:
Previously, I used ogen to generate boilerplate and handlers from the OpenAPI spec. Since that package carries a lot of dependencies like tracing with OTEL which we don’t use, I decided to remove it later on and use the stdlib HTTP server.
Basic app structure #
We will create two internal components, a FormHandler and MailService as
follows:
type MailService struct {
params MailServiceParams
}
type MailServiceParams struct {
SMTPFrom string
SMTPHost string
SMTPPort int
SMTPUser string
SMTPPass string
ToMail string
}
The handler will use a client from the
Friendly Captcha Go SDK
to validate requests and our MailService to send SMTP mail.
type FormHandler struct {
mailService service.MailService
frcClient friendlycaptcha.Client
}
You can use a constructor function to initialize them from main(). To avoid
hardcoding credentials, I have added viper as
a configuration manager.
msParams := service.MailServiceParams{
SMTPFrom: viper.GetString("smtp.from"),
// [...]
}
ms, err := service.NewMailService(msParams)
if err != nil {
log.Fatal().Err(err).Msg("error creating mail service")
}
frcClient := friendlycaptcha.NewClient(
viper.GetString("frc.apiKey"),
viper.GetString("frc.siteKey"))
fs, err := handler.NewFormHandler(ms, frcClient)
if err != nil {
log.Fatal().Err(err).Msg("error creating form handler")
}
Building the API Server #
Implement the response and request structs:
// MailRequest will be used in both the SMTP server and form handler,
// so we export the struct
type MailRequest struct {
Name string `json:"name"`
Email string `json:"email"`
Message string `json:"message"`
Subject string `json:"subject"`
FrcCaptchaSolution string `json:"frc-captcha-solution"`
}
// response can be kept private as it is only used within the handler
type response struct {
Message string `json:"message"`
Success bool `json:"success"`
}
For easy reponse handling, we start with a helper method that responds on the
http.ResponseWriter:
func (f *FormHandler) respond(w http.ResponseWriter,
msg string, statusCode int) {
res := response{
Message: msg,
Success: http.StatusOK == statusCode,
}
jsonRes, err := json.Marshal(res)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_, err = w.Write(jsonRes)
if err != nil {
log.Warn().Err(err).Msg("error writing response")
}
}
We implement a simple HTTP handler by implementing the ServeHTTP interface:
func (f *FormHandler) ServeHTTP(w http.ResponseWriter,
r *http.Request) {
// parsing the form data
err := r.ParseForm()
if err != nil {
f.respond(w,
"Error parsing form",
http.StatusInternalServerError)
return
}
// fill the request struct with form data
req := service.MailRequest{
Name: r.Form.Get("name"),
Email: r.Form.Get("email"),
Message: r.Form.Get("message"),
Subject: r.Form.Get("subject"),
FrcCaptchaSolution: r.Form.Get("frc-captcha-solution"),
}
// request and captcha validation follows...
log.Info().Interface("MailRequest", req).Msg("incoming form MailRequest")
err = f.mailService.Send(req)
if err != nil {
log.Error().Err(err).Msg("smtp error")
f.respond(w, "Error sending mail", http.StatusInternalServerError)
return
}
f.respond(w, "Message sent. I will get back to you asap!", http.StatusOK)
}
For validation we can use a simple value receiver. For me, keeping sensible length and ensuring a valid email is enough, but you can go way more in depth here.
const (
maxShort = 256
maxLong = 80000
)
func (r MailRequest) Validate() error {
if len(r.Name) == 0 || len(r.Name) > maxShort {
return errors.New("Invalid name")
}
if len(r.Email) == 0 || len(r.Email) > maxShort {
return errors.New("invalid email length")
}
if _, err := mail.ParseAddress(r.Email); err != nil {
return errors.New("invalid email format")
}
if len(r.Subject) == 0 || len(r.Subject) > maxShort {
return errors.New("invalid subject")
}
if len(r.Message) == 0 || len(r.Message) > maxLong {
return errors.New("invalid message")
}
if len(r.FrcCaptchaSolution) == 0 || len(r.FrcCaptchaSolution) > maxShort {
return errors.New("invalid captcha solution")
}
return nil
}
Add the request validation and captcha check to the handler to exit early on errors:
if err := req.Validate(); err != nil {
f.respond(w,
"Validation failed: " + err.Error(),
http.StatusBadRequest)
return
}
solution := req.FrcCaptchaSolution
pass, err := f.frcClient.CheckCaptchaSolution(
r.Context(), solution)
if err != nil {
log.Error().Err(err).Msg("captcha check error")
f.respond(w,
"Captcha error",
http.StatusInternalServerError)
return
}
if !pass {
f.respond(w,
"Invalid captcha",
http.StatusBadRequest)
return
}
And we are basically done on the API side. The SMTP service is easily put together with the standard library. We create a byte buffer and write some necessary headers to it:
var body bytes.Buffer
_, err = fmt.Fprintf(&body,
"Subject: %s \n%s\n\n",
mail.Subject,
"MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n")
if err != nil {
return fmt.Errorf("writing mail header failed: %w", err)
}
I like some styling in my emails, so I’ve built a HTML template. Using
html/template to fill a template is as easy as:
t, err := template.ParseFiles("template/mail.html")
if err != nil {
return fmt.Errorf("template parsing failed: %w", err)
}
err = t.Execute(&body, struct {
Name string
Message string
Email string
Subject string
}{
Subject: mail.Subject,
Name: mail.Name,
Message: mail.Message,
Email: mail.Email,
})
if err != nil {
return fmt.Errorf("template execution failed: %w", err)
}
Sending the completed email buffer is also a walk in the park thanks to Go’s rich stdlib:
auth := smtp.PlainAuth("", m.params.SMTPUser, m.params.SMTPPass, m.params.SMTPHost)
err = smtp.SendMail(
net.JoinHostPort(
m.params.SMTPHost,
strconv.Itoa(m.params.SMTPPort)),
auth,
m.params.SMTPFrom,
to,
body.Bytes(),
)
if err != nil {
return fmt.Errorf("smtp send failed: %w", err)
}
Now we just wire it all up. Since our FormHandler carries a ServeHTTP
method, we can pass it to the mux as a handler on our /form endpoint.
m := http.NewServeMux()
m.Handle("/form", fs)
srv := &http.Server{
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
Handler: m,
Addr: ":8080",
}
if err := srv.ListenAndServe(); err != nil {
log.Fatal().Err(err).Msg("error starting server")
}
Reverse proxy #
That’s all on the backend side, API ready to go. Pushed onto my webserver,
adding the following location directive for nginx:
location /form {
proxy_pass http://127.0.0.1:8080;
# security, headers, ...
}
This would now pass through requests to the go-form-mailer API on the /form
endpoint on this particular domain.
Dynamic form #
If you wanted to keep it extremely simple, you could just set the HTML <form>
action to /form, you could easily make the API return a full HTML response. I
wanted to keep things a bit more dynamic, so I hijack the form submit action and
differentiate between an error and a success response.
document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector("#form");
const submitBtn = document.getElementById("submit");
const alert = document.getElementById("alert");
const alertMessage = document.getElementById("alert-message");
form.addEventListener("submit", handleFormSubmit);
async function handleFormSubmit(event) {
// prevent the default form submission
event.preventDefault();
// disable submit button to prevent multiple clicks
submitBtn.disabled = true;
// convert multipart form data to urlencoded
const urlEncodedData = new URLSearchParams(new FormData(form)).toString();
try {
// post to go-form-mailer
const response = await fetch("/form", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: urlEncodedData,
});
const data = await response.json();
if (data.success) {
// hide inputs on successful submission
submitBtn.classList.add("hidden");
document
.querySelectorAll("input, textarea, .frc-captcha")
.forEach((el) => el.classList.add("hidden"));
}
showAlert(data.message);
} catch (error) {
showAlert("Error. Please try reaching out via email directly.");
} finally {
submitBtn.disabled = false; // re-enable the button
}
}
function showAlert(message) {
// show alert div, set message
alert.classList.remove("hidden");
alertMessage.textContent = message;
}
});
For the JavaScript to load within your Hugo page, the congo theme allows to extend the footer easily. One small tweak I wrote was to only load the partial when the page title is Contact:
{{ if eq.Title "Contact" }}
{{ partial "captcha-js" }}
{{ end }}
Within the partial, we load the JS snippet from above in mail.js and Friendly
Captcha library:
<script type="module" src="/js/widget.module.min.js" async defer></script>
<script nomodule src="/js/widget.min.js" async defer></script>
<script src="/js/mail.js"></script>
The captcha is then rendered with this simple HTML snippet:
<div class="frc-captcha" data-sitekey="your-sitekey"></div>

And that’s it. Just build a plain old HTML form and be done with it. Why dont you try it out and shoot me a message?
- TODO: Throw away the JS snippet, migrate to HTMX ❤

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.