If you want to know a simple way to add a beautiful, fully functional contact form to your Ghost website without paying for external services, you finally came to the right spot. I'm going to show you how to do it with a bit of html, css, javascript and server side scripting.

While this method will save you real money by avoiding additional monthly subscription fees, it also does not give away your precious visitors' contact data to other companies. This would be unavoidable, if you were to connect your form to external services.

This tutorial is a step-by-step guide and the best starting point for getting your contact page up and running. Once you are familiar with the concepts, also check out ghost-contact-form on Github, which complements this article.

Just a blog?

Whilst looking around for a modern, clean and easy to use blogging platform Ghost 3 became quickly the number one choice for our new atmospheric measurement project. As an open source project available on Github and built on a modern node.js stack, Ghost holds the promise to perform fast and integrate well with modern tools.

Probably one of the most basic features of any website is the ability to get in contact with the authors. To my surprise, setting up a simple contact form on this Ghost blog wasn't as easy as I expected it to be. Even more appalling, most solutions I came across point unerringly towards paid external services! Here is what the Ghost developers have to say on their forum:

How can i add a contact form to my blog without using external service?
No way to do it locally, you would need to use an external svc 🙂

What a bummer! If you are willing to spend $10 each month, just to get a contact form running, then don't read any further! There are plenty of paid options available to you.

This is not an option for me! What's the benefit of an open source, modern stack software, if it does not mean easy integration of a simple contact form? And how much do I need to pay for other services, if I want to add more advanced features to my blog, for example, a search or commenting function?

Of course, there is always the possibility to fork the Ghost project on Github and rewrite the server code to handle a contact form. It's like using a sledgehammer to crack a nut. That's definitely a complex and time consuming task. Even worse, it inhibits automatic updates to your blog software. There must be a better way to do this!

At this point I was actually looking around for just another blog software, applauding to this well opinionated post:

Why I don’t like the Ghost blogging platform anymore
I’m using the Ghost blogging platform for more than four years now - and I don’t like it anymore. Here’s why.

I think Kevin has a strong point to which the Ghost developers should better listen to.

However, reaching a dead end sometimes gives way to completely new ideas. And here is the question that lead me into the right direction:

What do these external services do that you can't do yourself?

After reading this post, you'll know the answer. Don't worry, I won't replicate these full-fledged services. But hey – all we want is a simple contact page!

How to add a Contact Page to Ghost

As you may have guessed by now, I am going to replace the paid external service with my own micro-service that I caringly call ghost-contact-svc.

This micro-service can be extended to perform other functions too, as you will see later. Also note that ghost-contact-svc has no additional dependencies, as it operates on the same stack as Ghost does.

On the front-end, I will add some simple html, css and javascript that you can copy-paste into the Ghost Admin interface. Here, I'm inspired by the work from Dana:

Beautiful Forms in Ghost — Using HTML
How to create beautiful contact forms in Ghost using HTML (without using Jotform, Typeform, or Contact Form 7).

I took his html and style files as a starting point as you will see later.

Run your own service

I am going to implement a micro-service using node.js with express, body-parser, and nodemailer. All it needs to do is listen for a post request on route /v1/contact, collect the form data and send it to an email address of your choosing.

var app = express();

app.use(cors({origin: process.env.ALLOW_ORIGIN, allowedHeaders: ['Content-Type', 'application/json']}));

app.post('/v1/contact', function(req, res) {
    if(validator.validate(req.body.email)) return sendEmail(req.body, res);  
    res.status(403).json({"validation": "no email"});
});

app.listen(process.env.PORT || 7000, function(){
	console.log('Listening on http://localhost:' + (process.env.PORT || 7000));
});
ghost-contact-svc.js (server part)

As you can see, the service listens on port 7000, and calls the function sendEmail(), if a correct email was provided. Note that we define CORS headers, which should be set to your blog URL in the environment variable ALLOW_ORIGIN.

While the validate() function is taken from a node package, we need to put together the email data in sendEmail() ourselves:

function sendEmail(data, res){
    var email = { "from": process.env.EMAIL_FROM, "to": process.env.EMAIL_TO};
    email.subject = 'MY BLOG - ' + (data.subject && data.subject.toUpperCase());

    const output = `
        <p>Hello,<p>
        <p>You got a new contact request.</p>
        <h3>Contact Details</h3>
        <ul><li>Name: ${data.name}</li><li>Email: ${data.email}</li></ul>
        <h3>Message:</h3>
        <p>${data.message}</p>
    `
    email.html = sanitize(output, { 
    	allowedTags: sanitize.defaults.allowedTags.concat([ 'img' ])
	});
    transporter.sendMail(email, function(error, info){
        if(error) return res.json({"sendEmail": "failed"});
        res.json({"sendEmail": "ok"});
    });
};
ghost-contact-svc.js (email sending part)

Basically, I am putting the provided data into a html message, sanitize it to prevent malicious code injections and send it to the configured email address. The configuration must be provided in a .env file in the working directory, where the following six variables are specified:

SMTP_HOST = mail.server.com
SMTP_USER = user@server.com
SMTP_PASS = strong password
ALLOW_ORIGIN = https://your-blog.com
EMAIL_FROM = noreply@your-blog.com
EMAIL_TO = your@email.com
.env file (replace variables with your own data)

Putting it all together and adding some rate limits, the back-end script can be written in only fifty code lines:

'use strict';
var express    = require('express');
var cors       = require('cors');
var bodyParser = require("body-parser");
var dotenv     = require('dotenv').config();
var nodemailer = require('nodemailer');
var smtpTrans  = require('nodemailer-smtp-transport');
var validator  = require("email-validator");
var sanitize   = require('sanitize-html');

var smtp  = { "auth": {}, "port": 465, "secure": true, "tls": {"rejectUnauthorized": false}, "debug": false};
smtp.host      = process.env.SMTP_HOST;
smtp.auth.user = process.env.SMTP_USER;
smtp.auth.pass = process.env.SMTP_PASS;
var transporter = nodemailer.createTransport(smtpTrans(smtp));

var app = express();
app.disable('x-powered-by');
app.use(bodyParser.urlencoded({limit: '1mb', extended: false}));
app.use(bodyParser.json({limit: '1mb'}));
app.use(cors({origin: process.env.ALLOW_ORIGIN, allowedHeaders: ['Content-Type', 'application/json']}));

app.post('/v1/contact', function(req, res) {
    if(validator.validate(req.body.email)) return sendEmail(req.body, res);  
    res.status(403).json({"validation": "no email"});
});

app.listen(process.env.PORT || 7000, function(){
	console.log('Listening on http://localhost:' + (process.env.PORT || 7000));
});

function sendEmail(data, res){
    var email = { "from": process.env.EMAIL_FROM, "to": process.env.EMAIL_TO};
    email.subject = 'MY BLOG - ' + (data.subject && data.subject.toUpperCase());

    const output = `
        <p>Hello,<p>
        <p>You got a new contact request.</p>
        <h3>Contact Details</h3>
        <ul><li>Name: ${data.name}</li><li>Email: ${data.email}</li></ul>
        <h3>Message:</h3>
        <p>${data.message}</p>
    `
    email.html = sanitize(output, { 
    	allowedTags: sanitize.defaults.allowedTags.concat([ 'img' ])
	});
    transporter.sendMail(email, function(error, info){
        if(error) return res.json({"sendEmail": "failed"});
        res.json({"sendEmail": "ok"});
    });
};
ghost-contact-svc.js (working node micro-service)

With the two files above, namely .env and ghost-contact-svc.js it's time to put the service to a first test.

Quick Install

I assume that you have node.js installed on your system as you had to do this before when you installed Ghost.

Now, put the two files from above, namely ghost-contact-svc.js and .env into an empty work directory and add the bash script install.sh to it.

#!/bin/bash

sudo npm -g install depcheck 
npm -y init
deps=$(depcheck |cut -d: -f1 |tr -d '* ' | tail -n +2)

for package in ${deps[@]}; do
	echo 'Installing package: ' ${package}
	npm install ${package} --save
done
install.sh (also make the file executable with "chmod +x install.sh")

When you execute the script with ./install.sh, it installs a dependency checker and subsequently downloads all needed node packages for this project. It also automatically creates the project manifest package.json. Feel free to use this script in all your other node.js projects :-).

Fire up your service

It is time to fire up the service with this one liner:

> node ghost-contact-svc.js
Listening on http://localhost:7000

If all goes well, you are greeted from your service accepting new incoming requests on port 7000. The service listens forever, but you can always exit it with Ctrl+C. For now, just leave it as is and open a new terminal window for testing.

First Light

As a first test, use a simple curl post request as follows:

> curl -v -d '{"email": "test@example.com", "name": "John Izzo", "subject": "feedback", "message": "First Light!"}' -H "Content-Type: application/json" -X POST http://localhost:7000/v1/contact

If the output ends with {"sendEmail":"ok"} you can go check your inbox, you should have received a new message:

Be a bit playful, send some crappy data to your service, omit some json key/value pairs, add some that are not recognized and try to break your service. I was not able to break it, but please report back to me, if you find a way to crash the service!

Watch out for some typical issues that you might run into during initial set-up. If you misconfigured the smtp credentials, you'll see a {"sendEmail":"failed"} response. If the email validation fails, you'll get a simple {"validation":"no email"} response.

Deploy it as a side-car to Ghost

While testing on your local machine is great, eventually you want to make this service available to the world. I suggest you place it behind your blog domain api.your-blog.com so you do not interfere with Ghost's routes.

I use nginx as a reverse proxy with a configuration that looks similar to:

server {

  listen 443 ssl http2;
  listen [::]:443 ssl http2;

  server_name api.your-blog.com;

  ssl_certificate /etc/letsencrypt/live/your-blog.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/your-blog.com/privkey.pem;
  ssl_protocols TLSv1 TLSv1.1 TLSv1.2;

  location / {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Host $http_host;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_pass http://127.0.0.1:7000;
  }

}
/etc/nginx/conf.d/api.conf

Once /etc/nginx/conf.d/api.conf is created, nginx must be reloaded with systemctl restart nginx for the changes to take effect. Finally, don't forget to punch an outgoing (not incoming!) whole in your firewall on port 465, otherwise you cannot reach the external smtp server.

In production, ghost-contact-svc.js must be run as a daemon. I use the following systemd unit for this purpose where you need to modify the User= and ExecStart= lines:

[Unit]
Description=Contactor microservice
After=network.target
StartLimitIntervalSec=0

[Service]
Type=simple
Restart=always
RestartSec=1
User=jo
ExecStart=/usr/bin/env node /home/jo/ghost-contact-form/ghost-contact-svc.js

[Install]
WantedBy=multi-user.target
/etc/systemd/system/ghost-contact.service

The service is started with systemctl start ghost-contact and made persistent with systemctl enable ghost-contact.

Once everything is in place and the service is running, test it from outside your remote server with a couple of curl requests.

> curl -v -d '{"email": "test@example.com", "name": "John Izzo", "subject": "feedback", "message": "Production Light!"}' -H "Content-Type: application/json" -X POST https://api.your-blog.com/v1/contact

A word on security and spamming

The implemented CORS headers will prevent other people from being able to consume your micro-service from their websites. That's a great security feature used by all modern browsers. Unfortunately, it won't hold off any script-kiddies.

Your endpoint is publicly available and can therefore be accessed by anyone from custom built scripts. In fact, you can test that easily yourself with the curl command as shown above.

Actually, you might be surprised that there is no bullet proof method to prevent spammers from sending fake data to your endpoint.

A common way to guard against those unwanted requests is to add a captcha challenge to your service at the expense of a really bad user experience.

If need be, look into implementing temporary tokens, or into the myriad of honey-spot techniques.

It's super easy to extend the service

To showcase that this service is like a Swiss army knife, I show you how to turn it into a static asset provider with just one additional code line. Add this line in ghost-contact-svc.js directly below the other app.use() instructions:

app.use('/v1/assets', express.static(__dirname + '/assets'));

Files that you put into a folder named assets (which must reside in the same directory as your service code), will now be served at https://api.your-blog.com/v1/assets.

Awesome! All your custom files can now be served from one place. No need to mess around with any Ghost directories. This will come in handy in a moment, when I provide the front-end code and style files.

Front-end matters

If you use Ghost in its default configuration, your new contact page can be configured directly in the Ghost Admin interface. Navigate to the Ghost Pages section and add a new page by clicking the New page button. Immediately, the page editor opens and you can type in a title, some introductory text or add some images.

Next, click on the circle icon, choose the html block and paste the following snippet in:

<form id="contact-form" method="post" data-format="inline">
    <input type="text" id="name" placeholder="Full Name" pattern=".{2,30}" required>
    <input type="email" id="email" placeholder="Email Address" required>
    <select id="subject" pattern=".{1,20}" required>
        <option value="" disabled selected>Please select...</option>
        <option value="comment">I want to give feedback</option>
        <option value="question">I want to ask a question</option>
    </select>
    <textarea rows="5" id="message" placeholder="Message" pattern=".{10,4000}" required></textarea>
    <input type="text" name="_norobots" style="display:none !important;">
    <button class="btn" type="submit"  id="submit" value="Send" onclick="formProcessor.process();return false;">Send</button>
    <span id="responsemsg"></span>
</form>

There are a couple of things worth noting here:

  • The form has no styling and looks quite ugly yet. I will provide a css style file to beautify it.
  • The input line with name _norobots is not shown to human users, but is there to detect robots if they fill out this field (a simple honeyspot technique).
  • Some basic html5 validation is added to the form, so browsers automatically apply some styling, if rules are violated.
  • All the magic happens when the user hits the Send button and formProcessor.process() is triggered.

Beautiful styling, please!

As mentioned earlier, I'm going to use the form style that Dana created for his blog article with some minor modifications:

form,input,select,textarea,button {
    font-family: avenir next,avenir,helvetica neue,helvetica,ubuntu,roboto,noto,segoe ui,arial,sans-serif;
	display: block;
	margin: 0 0 2rem 0;
    padding: 1rem 0;
    width: 100% !important;
    align-items: flex-start;
    box-sizing: border-box;
}
input,select,textarea {
    color: darkslategray;
    background-color: white;
    background-clip: padding-box;
    line-height: 1.5;
    border-radius: .5rem;
    padding: 1rem 4rem;
    border: 1px solid #ced4da;
    transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
}
select {
    appearance: none;
    -moz-appearance: none; 
    -webkit-appearance: none; 
}
select:required:invalid {
	color: silver;
}
input:focus, select:focus, textarea:focus {
    border-color: royalblue;
    box-shadow: none;
    -webkit-box-shadow: none;
}
button {
    margin-bottom: 0;
    color: white;
    background-color: royalblue;
    border-color: royalblue;
    user-select: none;
    line-height: 1.5;
    border-radius: .5rem;
    border: 1px solid transparent;
    transition: color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;
}
button:hover { 
	filter: brightness(80%); 
}
input:placeholder { 
	color: silver; 
}
option[value=""][disabled] {
    display: none;
    color: silver;
}
option {
    color: black;
}
ghost-contact.css

If you feel fancy, you can minify this style file before putting it into the asset directory of your micro-service. Make sure you can reach it under the route https://api.your-blog.com/v1/assets/ghost-contact.css. Here is the end result:

Run the demo yourself - https://github.com/styxlab/ghost-contact-form

Linking the front-end to the back-end

It's time to look into the formProcessor which is just a bit of client side javascript code. As I want to keep the back-end as lean as possible, I also add a bit of client side validation.

var formProcessor = (function(){

  "use strict";

  var constraints = {
      name: {
          presence: true,
          length: {
              minimum: 2,
              maximum: 30,
              message: "must be longer."
            }
        },
      email: {
        presence: true,
        email: true,
      },
      subject: {
        presence: true,
        length: {
          minimum: 1,
            message: "must be selected."
          }
      },
      message: {
        presence: true,
          length: {
               minimum: 10,
               maxumum: 4000,
               message: "must be longer."
            }
      },
      robot: {
        presence: true,
        length: {
               is: 0,
               message: "must be filled out."
            }
      }
  };

  function formAlert(text) {
      document.getElementById("responsemsg").innerHTML = "<br><p><em>" + text + "</em></p>";
    };

  function sendData(data) {
    formAlert("One second...");
    var postURL = "https://api.your-blog.com/v1/contact";
    var http = new XMLHttpRequest();
    http.open("POST", postURL, true);
    http.setRequestHeader("Content-Type", "application/json");
    data.source_url = window.location.href;
    http.send(JSON.stringify(data));
    http.onload = function() {
        formAlert("Thank you, your message has been sent!");
        document.getElementById("contact-form").reset();
    }
  };

  return ({
    process: function() {
        var attributes = {
        name: document.forms["contact-form"]["name"].value,
        email: document.forms["contact-form"]["email"].value,
        subject: document.forms["contact-form"]["subject"].value,
        message: document.forms["contact-form"]["message"].value,
        robot: document.forms["contact-form"]["_norobots"].value
      };
      validate.async(attributes, constraints)
      .then(function(success) {
          console.log("Success", success);
          sendData(success);
      })
      .catch(function(error) {
        console.log("ValidationError", error);
        formAlert(Object.values(error)[0][0]);
      })  
    }
  });

}());
formProcessor.js

Read this file from bottom to top, starting with the process() function. Basically, I collect the form data into the variable attributes which is subsequently validated against some constraints. Only if the constraints are met, do we initiate the sendData() function.

Download the package validate.min.js and put this file together with formProcessor.js into the asset folder so it gives the style file some nice company.

The magic link is made in function sendData() where the form data is send over to our micro-service via the familiar route https://api.your-blog.com/v1/contact. This replaces the curl requests that we used for testing before.

Ghost Code Injection

Finally, all front-end files must be included in the loading of your contact page. This is easily achieved by the following code injections.

Ghost Admin Interface - Contact page

Inject this snippet into the Ghost Page header of your contact page:

<link rel="stylesheet" href="https://api.your-blog.com/v1/assets/ghost-contact.css">

Likewise inject this snippet into the Page footer of your page:

<script src="https://api.your-blog.com/v1/assets/validate.min.js" type="text/javascript"></script>
<script src="https://api.your-blog.com/api/assets/formProcessor.js" type="text/javascript"></script>  

That's it. Your beautiful form should be fully functional!

Unique Features of this Solution

While running your own micro-service is more involved than subscribing to an externally hosted service, the solution shines with these noteworthy benefits:

  • Your visitor's contact data remains with you and is not shared with any third party providers.
  • You have full control of the data and code of your service.
  • The solution is written with standard web tools and can be easily extended and adapted to ever changing needs.
  • The Ghost installation is completely unaltered, which will save you multiple headaches during the next updates and future upgrades.
  • The same principle can be used to enhance your Ghost blog with other self-built services acting as integrations, plugins or addons.
  • No additional costs, if you run your micro-service on the same server where you host your Ghost blog software.

Conclusions

With this guide, you should be able to set-up a fully functional contact page in Ghost without locking into another external service provider. I also created the ghost-contact-form solution on Github, where further code improvements will be made.

While I focus on Ghost in this article, ghost-contact-form is actually also an ideal solution for any other static site generators, like Gatsby, Hugo, Hexo and others. Only the front-end needs to be adjusted, the back-end stays completely the same!

One more thing...

...if you want to see ghost-contact-form in action, don't forget to drop me a line on our Contact Us page!