Adding a members-only contact form with Ghost

Adding a members-only contact form with Ghost
Photo by Liam Truong / Unsplash

When I created the contact page for this site, I went through a few options before going with a fairly vanilla Formspree setup. But once I had that, I wasn't really happy with the options. AJAX forms worked ok, POST forms with reCAPTCHA worked ok, but I didn't really want one without the other.

For those not aware, reCAPTCHA is the 'challenge engine' from Google that in one form presents a challenge ("select all of the school buses"), and in another sits quietly analysing to see whether you're a bot.

After trying and failing several times, I scrapped trying to combine both deciding decided that an AJAX form is friendlier for users, but how could I prevent the form from being abused by spambots?

Ghost Members Only

The member signup process in Ghost is very seamless and doesn't collect any information that isn't necessary, and especially useful for those responsible for this data: Ghost doesn't use passwords for members.

Signup can be either just an email address or an email address and name. Either way, fields that you'd expect to enter on a contact form.

Once you've hit the sign up button, you're emailed a 'magic link' that you click to sign in. Future sign-ins use another magic link. The nice thing about using this for contact form messages is: we've already validated the email address.

After deciding this would be the best route, I took a look at the contact form template for the theme I'm using (Joben from Biron Themes). If your theme doesn't have a specific template for the contact page, you'll need to create a file to do so (Norbert from BironThemes has you covered there too, actually, with this guide) because it uses Handlebars syntax to decide what to serve to the user.

The form I ended up with is below, but the key points are:

  • The handlebars conditional {{#if @member}} will show everything within that block to a logged-in member (both free and paid).
  • Because we're certain the form will only be shown to members, we can pre-fill member attributes, confident that there won't be errors. In this case, we're using {{}} and {{}}. We're also adding readonly to those particular form fields, as well as adding some custom CSS to target readonly fields:
input[readonly] {
	background-color: lightgray;
 	color: gray;
 	box-shadow: 0 0 0 0;
  • If the user isn't a logged in member, they see what's behind door number two: {{else}}—sign-in and sign-up links, as well as an alternative contact method (Twitter DMs).

The Form

<div class="content">
	{{#if @member}}
        <script src=""></script>
        <h4>{{t "Get in touch:"}}</h4>
        <div class="contact-form m-b-lg">
        	<p>Please send a message using the form below. Alternatively, DMs are open <a href="">@techbitsio</a>.</p>
			<div id="formBlock">
            	<form id="contact-form">
            		<input type="text" name="name" id="name" title="{{}}" value="{{}}" readonly>
              		<input type="text" name="email" id="email" title="{{}}" class="email" value="{{}}" readonly>
              		<textarea rows="5" name="message" id="message" title="{{t "Message"}}" placeholder="{{t "Message"}}" required></textarea>
              		<div class="text-center">
                		<button class="btn btn-default" type="submit" value="Send">Submit</button>
        <div class="alert alert-danger center-block submit-fail display-none">Something has gone wrong...<br><a href="#"></a></div>
        <div class="alert alert-success center-block submit-success display-none">Thank you. Your message has been sent. Please sit tight while the packets are reassembled by an intern.<br><a href="#"></a></div>

        $(document).ready(function() {
          $('#contact-form').submit(function(e) {
              var name = $('#inputName')
              var email = $('#inputEmail')
              var message = $('#inputMessage')
              if(name.val() == "" || email.val() == "" || message.val() == "") {
                return false;
              else {
                  method: 'POST',
                  url: '',
                  data: $('#contact-form').serialize(),
                  datatype: 'json'

        <h4>{{t "Get in touch:"}}</h4>
        <div class="contact-form m-b-lg">
			Please <a class="signup-link header-cta" href="/signup/">{{t "Sign Up"}}</a> or <a class="signin-link" href="/signin/">{{t "Sign In"}}
            </a> to send a message. Alternatively, DMs are open <a href="">@techbitsio</a>.

The Results

Users that aren't logged in will see this basic message:

After signing up, or in, and clicking the magic link email, the form loads with the user's details filled:

Once the user hits submit, we show an error or success message depending on the server response:

Potential issues

There are caveats to using this method, although I don't think they are too bad.

Firstly, even those the form fields are set to readonly, users are still able to edit this in the browser developer tools, and so the resulting email information would be different from a name/email that doesn't match their account. Is this a big deal? The user would still need to be a signed-in member, and there are potentially other ways to include data that the user wouldn't know to change. For example, a hidden form field, pre-filled with the hash of the user's name and email. Another hidden field created by the javascript on send that hashes the name and email included in the form. If they match, it's either genuine, or you have a really smart user.

The other potential issue is that the sign-in requirement might mean that users might be put off from messaging. Rewording the text might help, but for a website like this where there's a low chance of users emailing, and the user isn't a paying customer, I think it's ok. Having a link/other contact information as a backup is good.

I enjoyed coming up with this workaround as it fits in really nicely with the core Ghost member features. If you find it useful, or you have a suggestion for improving it, please let me know in the comments below, or over at @techbitsio.

Great! Next, complete checkout for full access to
Welcome back! You've successfully signed in
You've successfully subscribed to
Success! Your account is fully activated, you now have access to all content
Success! Your billing info has been updated
Your billing was not updated