How To Build A Serverless Contactform With Zeit

Build a serverless contact form with Go on Zeit! Dive into the code and discover valuable lessons for deploying on this developer-friendly platform. Learn about environment variables, deployment descriptors, and more. Ready to go serverless? Let's get started!

How To Build A Serverless Contactform With Zeit
Page content

Unless you’ve spent the last few months in outer space, or at the very least out of reach from the Internet, you’ve seen that serverless is one of the hottest topics when it comes to building apps. Over the last few months we’ve seen AWS announcing a ton of things at their annual user conference, Google announced support for Go in private beta and serverless containers in private alpha and even Gitlab announced some form of serverless support. With so many massive players it’s easy to forget smaller ones, but those smaller ones are quite often pretty interesting.


When I started looking at serverless platforms one of the “smaller” ones I came across was Zeit. A company that has their mission to “Make Cloud Computing as Easy and Accessible as Mobile Computing.” In a smaller font underneath, it reads: “We build products for developers and designers. And those who aspire to become one.” Which to me is equally interesting as it sets a high bar for how their products should work. So, I set out to build a simple function that would serve as the backend for the contact form on

To get started I’ll walk you through the code and look at a few lessons I learned while building a Go app to run on Zeit. The app takes in the request coming over HTTP, validates the reCAPTHCA to make sure the form wasn’t filled out by a bot and sends an email to a pre-determined email address. The full code is on available on GitHub

Disregarding the less important files for a second, the ones that are important are:

β”œβ”€β”€ .env_template <-- A template file with the environment variables needed for the function
β”œβ”€β”€ index.go      <-- The actual function code
└── now.json      <-- Deployment descriptor for Zeit

Zeit handles secrets in a very similar way to regular environment variables and that makes writing code pretty easy. The same os.Getenv() works for both secrets as well as regular environment variables. The downside I found, though, is that you can’t update the secrets with new information. Instead, you’ll have to delete and recreate them. In my case, the .env_template has the environment variables I need and a specific target in the Makefile removes and creates the secrets.

Deployments, either through the macOS app or the CLI, rely on a now.json to tell the Zeit builders what to make from the code you’re uploading. It’s quite easy to get to know the things you need to fill out and their documentation helps out too. You can have multiple builds sections in your file if you want to have a monorepo with all your frontend and backend code (usually that isn’t a good idea, but if you’re experimenting with building apps why not use that feature?). The environment variables are listed with either their value or prefixed with an @ to denote they’re secrets.

The, by far, biggest file is the index.go which contains all the function logic. Breaking it down a bit, the sections are:

// Constants
const (
  // The URL to validate reCAPTCHA
  recaptchaURL = ""

// Variables
var (
  // The reCAPTCHA Secret Token
  recaptchaSecret = os.Getenv("RECAPTCHA_SECRET")
  // The email address to send data to
  emailAddress = os.Getenv("EMAIL_ADDRESS")
  // The email password to use
  emailPassword = os.Getenv("EMAIL_PASSWORD")
  // The SMTP server
  smtpServer = os.Getenv("SMTP_SERVER")
  // The SMTP server port
  smtpPort = os.Getenv("SMTP_PORT")

A section reading in the environment variables and for the code it doesn’t matter whether they are secrets or not.

// Handler is the main entry point into tjhe function code as mandated by ZEIT
func Handler(w http.ResponseWriter, r *http.Request) {
  // HTTPS will do a PreFlight CORS using the OPTIONS method.
  // To complete that a special response should be sent
  if r.Method == http.MethodOptions {
    response(w, true, "", r.Method)

  // Parse the request body to a map
  buf := new(bytes.Buffer)
  u, err := url.ParseQuery(buf.String())
  if err != nil {
    response(w, false, fmt.Sprintf("There was an error sending your form data: %s", err.Error()), r.Method)

  // Prepare the POST parameters
  urlData := url.Values{}
  urlData.Set("secret", recaptchaSecret)
  urlData.Set("response", u["g-recaptcha-response"][0])

  // Validate the reCAPTCHA
  resp, err := httpcall(recaptchaURL, "POST", "application/x-www-form-urlencoded", urlData.Encode(), nil)
  if err != nil {
    response(w, false, fmt.Sprintf("There was an error sending your form data: %s", err.Error()), r.Method)

  // Validate if the reCAPTCHA was successful
  if !resp.Body["success"].(bool) {
    response(w, false, fmt.Sprintf("There was an error sending your form data: %s", fmt.Sprintf("%v", resp.Body["error-codes"])), r.Method)

  // Set up email authentication information.
  auth := smtp.PlainAuth(

  // Prepare the email
  mime := "MIME-version: 1.0;\nContent-Type: text/plain; charset=\"UTF-8\";\n\n"
  subject := fmt.Sprintf("Subject: [BLOG] Message from %s %s!\n", u["name"][0], u["surname"][0])
  msg := []byte(fmt.Sprintf("%s%s\n%s\n\n%s", subject, mime, u["message"][0], u["email"][0]))

  // Connect to the server, authenticate, set the sender and recipient,
  // and send the email all in one step.
  err = smtp.SendMail(
    fmt.Sprintf("%s:%s", smtpServer, smtpPort),
  if err != nil {
    fmt.Printf("[BLOG] Message from %s %s\n%s\n%s\nThe message was not sent: %s", u["name"][0], u["surname"][0], u["message"][0], u["email"][0], err.Error())
    response(w, false, "There was an error sending your email, but we've logged the data...", r.Method)

  // Return okay response
  response(w, true, "Thank you for your email! I'll contact you soon.", r.Method)

The function that is used as the entry point into your code is called Handler and that is something you cannot change.

In the code, you’ll find two other methods:

  • response, which is a method that takes care of replying to the incoming request and since most replies are the same, having a single method to do so made sense
  • httpcall, which calls the reCAPTCHA service

Why didn’t I move some stuff into separate files to make it easier to read you ask? Well, as it turns out the Zeit builder for Go treats every file as a separate artifact to build so having a http.go (for the reCAPTCHA interaction) and a main.go for the rest wasn’t possible. The other thing I found is that the builder looks for the first “exported” method name and disregards the rest. I realize I could fix that by simply moving the Handler function to the top and not worry about the rest, but a better alternative would be to check whether the function is exported and has the 2 parameters (the ones required by Zeit and no more than those two).


Having said all that, my serverless contactform works perfectly after I found the guardrails I needed to stay within and to be honest those guardrails aren’t that bad. In fact, I’d say that with a pretty generous free plan, the amount of different runtimes (like PHP, Next.js, or even Markdown) coupled with the ease of use, and some of the things the team tweeted about Zeit has a pretty interesting time ahead (yes, I did totally want to make a time related pun). I hope they’ll continue their service for a long time not in the very least because they’re awesome contributors to Open Source.

Cover image by Pixabay