Skip to content

How to Make a Responsive Contact Form Using Vue.js and Go ā€‹

Having a contact form on your website can make life easier for some of your clients. That's why in this post we'll show you how we made ours. And no, we won't use one of those SaaS form handling, potentially GDPR violating, privacy nightmare products. You will have the control of your data šŸ«µ and you will need to host the backend somewhere, like Digilol managed servers.

The Frontend ā€‹

We implemented two components for the contact form: the form itself and a modal pop-up.

Form Component ā€‹

We use Vitepress for our website so we used its button component and theme as well.

Here is the code for the contact form component. Omitted some irrelevant parts because it originally contained a vertical divider and alternative contact method logos.

DGContact.vue:

jsx
<script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme'
import { VPButton } from 'vitepress/theme'
import DGModal from './DGModal.vue'

const props = defineProps<{
  formEndpoint: string
}>()
</script>

<script lang="ts">
import axios from 'axios'

export default {
  data() {
    return {
      form: {
        name: '',
        email: '',
        service: '',
        message: ''
      },
      modalOpen: false,
      result: '',
      altEmail: ''
    }
  },
  methods: {
    async submit() {
      axios.post(this.formEndpoint, this.form)
      .then(response => {
        this.modalOpen = true;
        this.result = "Received! Thank you."
        this.altEmail = '';
      })
      .catch(error => {
        this.modalOpen = true;
        this.altEmail = `mailto:hello@domain.tld?subject=${this.form.service}&body=${this.form.message}`
        if (error.request.status == 429) {
                this.result = "Slow down! You made too many requests, try again later."
        } else {
          this.result = "Something went wrong. Please contact us directly or try again later.";
        }
      });
    }
  }
}
</script>

Here we use Axios to make a POST request to our backend on submit. Also display a modal saying received on success and on failure the modal displays an additional button that launches an email client when clicked.

jsx
<template>
  <div id="contact" class="contact-section">
    <div class="contact-content">
      <h2 class="contact-title">Contact us</h2>
      <div class="contact-wrapper">
        <div class="contact-form">
          <DGModal :show="modalOpen" @close="modalOpen = false" :altEmail="altEmail">
            <template #header>
              <h3>{{ result }}</h3>
            </template>
          </DGModal>

          <form @submit.prevent="submit">
            <p>Leave us a message and we'll respond within 48 hours.</p>
            <div class="form-element top name-container">
              <label for="name">Name</label>
              <input type="text" name="name" id="name" placeholder="Name Surname" v-model="form.name" required />
            </div>
            <div class="form-element top email-container">
              <label for="email">Email</label>
              <input type="email" name="email" id="email" placeholder="name@example.net" v-model="form.email" required />
            </div>
            <div class="form-element">
              <label for="service-selector">I am interested in</label>
              <select id="service-selector" name="service" v-model="form.service" required>
                <option value="" disabled>Pick a service</option>
                <option value="Software Development">Software Development</option>
                <option value="Consulting">Consulting</option>
              </select>
            </div>
            <div class="form-element">
              <label for="message">Message</label>
              <textarea id="message" name="message" v-model="form.message" placeholder="Dear Digilol Team,&#10;Is smoking bad for my computer?" required></textarea>
            </div>
            <div class="form-element submit">
              <VPButton text="Submit" />
            </div>
          </form>
        </div>
...

On error or success this is component that will be displayed over the form.

DGModal.vue:

jsx
<script setup lang="ts">
import type { DefaultTheme } from 'vitepress/theme'
import { VPButton } from 'vitepress/theme'

const props = defineProps<{
  show: Boolean
  altEmail?: string
}>()
</script>

<template>
  <Transition name="modal">
    <div v-if="show" class="modal-mask">
      <div class="modal-container">
        <div class="modal-content">
          <div class="modal-header">
            <slot name="header" />
          </div>
          <div class="modal-body">
            <slot name="body" />
          </div>
          <div class="modal-footer">
            <slot name="footer">
            <VPButton text="Close" @click="$emit('close')"  />

            <VPButton v-if="altEmail" text="Email directly" @click="$emit('close')" :href="altEmail" />
            </slot>
          </div>
        </div>
      </div>
    </div>
  </Transition>
</template>

<style>
.modal-mask {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: var(--vp-c-bg-soft);
  display: flex;
  transition: opacity 0.3s ease;
}

.modal-container {
  display: flex;
  justify-content: center;
  align-items: center;
  margin: auto;
  padding: 20px 30px;
  border-radius: 5px;
  border: 1px solid var(--vp-button-brand-active-bg);
  background-color: var(--vp-c-bg);
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.33);
  transition: all 0.3s ease;
}

.modal-content {
  display: block;
}

.modal-header h3 {
  margin-top: 0;
}

.modal-body {
  margin: 20px 0;
}
...

This component uses Vue.js animations (fade-in/out) like so:

css
.modal-enter-from {
  opacity: 0;
}

.modal-leave-to {
  opacity: 0;
}

.modal-enter-from .modal-container,
.modal-leave-to .modal-container {
  -webkit-transform: scale(1.1);
  transform: scale(1.1);
}
</style>

Register a Global Component in Vitepress ā€‹

If you use Vitepress like us, you will need to register the contact form component like so:

.vitepress/theme/index.ts:

ts
import { h } from 'vue'
import type { Theme } from 'vitepress'
import DefaultTheme from 'vitepress/theme'
import './style.css'

import DGContact from './components/DGContact.vue'

export default {
  extends: DefaultTheme,
  Layout: () => {
    return h(DefaultTheme.Layout, null, {
    })
  },
  enhanceApp({ app, router, siteData }) {
    app.component('DGContact', DGContact)
  }
} satisfies Theme

Now you should be able to use the DGContact component like so:

html
<DGContact formEndpoint="https://contact-form.digilol.net/form" />

The Backend ā€‹

For the backend we will write a small Go program that launches an HTTP server to handle form requests and forward them to an email address.

main.go:

go
package main

import (
        "bytes"
        "encoding/json"
        "flag"
        "html/template"
        "log"
        "net/http"
        "os"
        "time"

        "github.com/go-chi/chi/v5"
        "github.com/go-chi/chi/v5/middleware"
        "github.com/go-chi/cors"
        "github.com/go-chi/httprate"
        mail "github.com/xhit/go-simple-mail/v2"
        "gopkg.in/yaml.v3"
)

type Config struct {
        Server struct {
                Host    string `yaml:"host"`
                Port    string `yaml:"port"`
                Timeout struct {
                        Server time.Duration `yaml:"server"`
                        Write  time.Duration `yaml:"write"`
                        Read   time.Duration `yaml:"read"`
                        Idle   time.Duration `yaml:"idle"`
                } `yaml:"timeout"`
        } `yaml:"server"`
        Cors struct {
                AllowedOrigins []string `yaml:"allowed-origins"`
        } `yaml:"cors"`
        RateLimit struct {
                Requests int           `yaml:"requests"`
                Duration time.Duration `yaml:"duration"`
        } `yaml:"rate-limit"`
        Email struct {
                SMTP struct {
                        Host       string `yaml:"host"`
                        Port       int    `yaml:"port"`
                        Username   string `yaml:"username"`
                        Password   string `yaml:"password"`
                        Encryption string `yaml:"encryption"`
                        Timeout    struct {
                                Connect time.Duration `yaml:"connect"`
                                Send    time.Duration `yaml:"send"`
                        } `yaml:"timeout"`
                } `yaml:"smtp"`
                Sender       string `yaml:"sender"`
                Recipient    string `yaml:"recipient"`
                TemplatePath string `yaml:"template-path"`
        } `yaml:"email"`
}

type formRequest struct {
        Name    string `json:"name"`
        Email   string `json:"email"`
        Service string `json:"service"`
        Message string `json:"message"`
}

var (
        cfg           *Config
        emailTemplate *template.Template
        smtpServer    *mail.SMTPServer
)

func sendEmail(form formRequest) error {
        var b bytes.Buffer
        err := emailTemplate.Execute(&b, form)
        if err != nil {
                return err
        }
        email := mail.NewMSG()
        email.SetFrom(cfg.Email.Sender).
                AddTo(cfg.Email.Recipient).
                SetReplyTo(form.Name + " <" + form.Email + ">").
                SetPriority(mail.PriorityHigh).
                SetSubject("Contact form inquiry from " + form.Name)
        email.SetBody(mail.TextHTML, b.String())
        if email.Error != nil {
                return email.Error
        }

        smtpClient, err := smtpServer.Connect()
        if err != nil {
                return err
        }
        return email.Send(smtpClient)
}

// On form submit
func formHandler(w http.ResponseWriter, r *http.Request) {
        var f formRequest
        err := json.NewDecoder(r.Body).Decode(&f)
        if err != nil {
                w.WriteHeader(http.StatusBadRequest)
                return
        }
        if f.Name == "" || f.Email == "" || f.Service == "" || f.Message == "" {
                w.WriteHeader(http.StatusBadRequest)
                return
        }
        err = sendEmail(f)
        if err != nil {
                log.Println("Failed to email:", err)
                w.WriteHeader(http.StatusInternalServerError)
        }
}

// Parses YAML config
func newConfig(configPath string) (*Config, error) {
        c := &Config{}
        f, err := os.Open(configPath)
        if err != nil {
                return nil, err
        }
        defer f.Close()
        d := yaml.NewDecoder(f)
        if err := d.Decode(&c); err != nil {
                return nil, err
        }
        return c, nil
}

func main() {
        var cfgPath string
        flag.StringVar(&cfgPath, "config", "config.yaml", "path to configuration file")
        flag.Parse()
        var err error
        cfg, err = newConfig(cfgPath)
        if err != nil {
                log.Fatal(err)
        }

        emailTemplate, err = template.ParseFiles(cfg.Email.TemplatePath)
        if err != nil {
                log.Fatal("Failed to parse email template:", err)
        }

	// You need an SMTP server to send emails.
        smtpServer = mail.NewSMTPClient()
        smtpServer.Host = cfg.Email.SMTP.Host
        smtpServer.Port = cfg.Email.SMTP.Port
        smtpServer.Username = cfg.Email.SMTP.Username
        smtpServer.Password = cfg.Email.SMTP.Password
        switch cfg.Email.SMTP.Encryption {
        case "None":
                smtpServer.Encryption = mail.EncryptionNone
        case "SSL/TLS":
                smtpServer.Encryption = mail.EncryptionSSLTLS
        case "STARTTLS":
                smtpServer.Encryption = mail.EncryptionSTARTTLS
        }
        smtpServer.ConnectTimeout = cfg.Email.SMTP.Timeout.Connect
        smtpServer.SendTimeout = cfg.Email.SMTP.Timeout.Send

        r := chi.NewRouter()
        r.Use(middleware.Heartbeat("/"))
        r.Use(middleware.RealIP)
        r.Use(middleware.Logger)
        r.Route("/form", func(r chi.Router) {
                r.Use(httprate.LimitByIP(cfg.RateLimit.Requests, cfg.RateLimit.Duration))
                r.Use(cors.Handler(cors.Options{
                        AllowedOrigins: cfg.Cors.AllowedOrigins,
                        AllowedMethods: []string{"POST"},
                }))
                r.Use(middleware.AllowContentType("application/json"))
                r.Post("/", formHandler)
        })

        hs := &http.Server{
                Addr:         cfg.Server.Host + ":" + cfg.Server.Port,
                Handler:      r,
                ReadTimeout:  cfg.Server.Timeout.Read * time.Second,
                WriteTimeout: cfg.Server.Timeout.Write * time.Second,
                IdleTimeout:  cfg.Server.Timeout.Idle * time.Second,
        }
        log.Fatal(hs.ListenAndServe())
}

We can specify a template file for the emails that will be sent:

email.html:

html
<html>
    <body>
        <p>Contact form inquiry.</p>
        <p>
            <b>Name:</b> {{ .Name }}<br>
            <b>Email:</b> {{ .Email }}<br>
            <b>Service:</b> {{ .Service }}
        </p>
        <p>{{ .Message }}</p>
    </body>
</html>

And also a configuration file in YAML format that allows to modify many parameters:

config.yaml:

yaml
server:
  host:
  port: 8080
  timeout:
    server:
    write:
    read:
    idle:
cors:
  allowed-origins:
    - https://www.digilol.net
rate-limit:
  requests: 4
  duration: 12h
email:
  smtp:
    host: mail.domain.tld
    port: 465
    username: hello@domain.tld
    password: s3cret
    encryption: SSL/TLS
    timeout:
      connect: 30s
      send: 30s
  sender: Digilol Contact Form <hello@domain.tld>
  recipient: info@domain.tld
  template-path: email.html

License ā€‹

All of the code included in this post is licensed under The GNU General Public License v3.0. The license text is available here.