Skip to main content

Simple API for a Gatsby Mailing List Sign Up Form

Pulblished:

Updated:

Comments: counting...

Learn how to set up a backend API with Apollo GraphQL and MongoDB, and use it to collect mailing list subscription data from users on a Gatsby site.

Setup

This article assumes you already have NodeJS, NPM, and MongoDB installed on your workstation.

Lets just jump right into this, pop open a command line shell and run npm install --global gatsby-cli, followed by gatsby new subscription-example, that will get you a basic Gatsby starter app all ready to go.

You can at this point open up the project folder in your favorite text editor (which is definitely VSCodium, right?), bring up the in-editor terminal, and give your knuckles a good crack (or don’t? They’re your hands after all). You are now ready to proceed to the next step.

Speaking of subscription forms, you can subscribe to my blog at the bottom of this page 🤓

Apollo Server

We are going to set up an Apollo server on ExpressJS, and in the end we will serve the Gatsby frontend from the same Express server for a seamless single-host setup.

Let’s first install some dependancies:

npm install --save graphql react-apollo apollo-server apollo-boost apollo-server-express isomorphic-fetch express mongoose morgan cors dotenv esm typescript

What was all that for?

  • graphql
    • Apollo uses GraphQL to resolve queries.
  • react-apollo, apollo-server, apollo-boost, apollo-server-express, isomorphic-fetch
    • These are all for Apollo communication between the frontend and backend.
  • express, mongoose, morgan
    • Express will be our server, Mongoose will link the server up to our MongoDB database, and Morgan is the standard traffic logger for Express.
  • cors
    • CORS lets us control what URL’s are allowed to make Apollo queries.
  • dotenv
    • Loads variables from the .env file in the root directory of the project.
  • esm
    • Lets us use ES6 syntax in plain NodeJS files.
  • typescript
    • Just a dependancy that needs to be manually installed

Great, now let’s make a folder in the root of the project called server, we are going to put the following 5 files in the server folder. I have added extra comments in the code to make it more readable, but feel free to ask any questions in the comments section at the bottom of this page if something isn’t totally clear.

server/database.js

This is where we will setup the connection to MongoDB.

import mongoose from 'mongoose';

// Check for DATABASE_URL from .env, use localhost as a fallback
const dbURL =
  process.env.DATABASE_URL ||
  'mongodb://localhost:27017/subscriptionexample';

// Connect to MongoDB
mongoose.connect(dbURL).catch((err) => {
  console.log(err);
});

// We will export 'db' as the connection for use elsewhere
const db = mongoose.connection;

// If there is a connection error, retry every 10 seconds
db.on('error', () => {
  setTimeout(() => {
    mongoose.connect(dbURL).catch((err) => {
      console.log(err);
    });
  }, 10000);
});

export default db;

server/schema.js

Here we define a model of the data types we will be using, you can modify these as needed, just remember what to change for the later steps so they match.

Note: This is a very basic example, you would do well to add a scalar for the Email type to ensure you only get Email addresses instead of just any string.

import { gql } from 'apollo-server-express';

const schema = gql`
  # At the time of writing, there is a bug that will crash
  # the application if there isn't at least one query
  # defined in the schema, so we have a dummy query.

  type Query {
    dummy: String
  }

  # We will use the signUp mutation to add data to the database,
  # we want to take in some user information as strings,
  # and reply with a response.

  type Mutation {
    signUp(
      firstName: String!
      lastName: String!
      email: String!
    ): Response
  }

  # The response will also be a string.

  type Response {
    result: String
  }
`;

export default schema;

server/dbActions.js

In this file, we will make sure the subscriber is new and not already in the database, attempt to add them to the database, and return a response for Apollo to send back the the client upon completion.

import db from './database';

// Define the database collection name as a constant, we could just as well
// type in 'subscribers' everywhere that subscribersCollection is used, but
// this way is cleaner.
const subscribersCollection = 'subscribers';

// This main function for adding a new subscriber to the database gets exported
// for use elsewhere
export const addSubscriber = async (args) => {
  // Check that this Email is unique from the database
  if (await checkForDuplicateEmail(subscribersCollection, args)) {
    // Respond to client via Apollo
    return 'This Email address is already subscribed!';
  } else {
    // Insert data into database, return reponse to Apollo
    return dbInsertSubscriber(subscribersCollection, args);
  }
};

// This function is called from the main addSubscriber function above
const checkForDuplicateEmail = (collectionName, { email } = args) => {
  return new Promise(async (resolve) => {
    if (await checkCollectionExists(collectionName)) {
      // if collection exists, check if new Email is already in db
      db.collection(collectionName).findOne({ email }, (err, data) => {
        if (err) console.log(err);
        if (data) {
          // Email already exists
          resolve(true);
        } else {
          // Email is unique and can be added to db
          resolve(false);
        }
      });
    } else {
      // db collection does not exist, skip checking for Email
      resolve(false);
    }
  });
};

// This function is called from the checkForDuplicateEmail function above
const checkCollectionExists = (collectionName) => {
  return new Promise((resolve) => {
    db.db
      .listCollections({ name: collectionName })
      .next((err, collectionInfo) => {
        if (err) console.log(err);
        if (collectionInfo) {
          // db collection exists
          resolve(true);
        } else {
          // db collection does not exist, it will
          // be created automatically when data is inserted
          resolve(false);
        }
      });
  });
};

// This function is called from the main addSubscriber function above
const dbInsertSubscriber = (collectionName, args) => {
  return new Promise((resolve) => {
    // insert subscriber data, I've also added some defaults
    // that I wanted in the database here - start date (today),
    // cancellation date (null), and active (true), anything you
    // add here will be put into every database entry so keep that
    // in mind.
    db.collection(collectionName).insertOne(
      {
        ...args,
        subscriptionDate: new Date(),
        cancelDate: null,
        active: true,
      },
      (err) => {
        if (err) {
          console.log(err);
          // these responses will get sent back to the client via Apollo
          resolve(
            'Error: Request rejected - pleasae try again later...',
          );
        } else {
          resolve('Your subscription has been submitted, thank you!');
        }
      },
    );
  });
};

server/resolvers.js

This is where we define how requests are handled or ‘resolved’.

import db from './database';
import { addSubscriber } from './dbActions';

const resolvers = {
  Mutation: {
    // parent is required here even though it is not used directly
    signUp: async (parent, args) => {
      // Make sure we can talk to the database, let the client know if we can't
      if (db.readyState !== 1) {
        return {
          result:
            'Error: Database unreachable - please try again later...',
        };
      } else {
        // Run the addSubscriber function from our dbActions.js file
        const result = await addSubscriber(args);
        // Return the response to Apollo to be sent to the client
        return { result };
      }
    },
  },
};

export default resolvers;

server/index.js

Here we will tie all of that together.

import cors from 'cors';
import express from 'express';
import schema from './schema';
import resolvers from './resolvers';
import { ApolloServer } from 'apollo-server-express';
const morgan = require('morgan');

// Source the .env file if it exists
require('dotenv').config();

// Set fallbacks for GraphQL/Apollo URL, port, and CORS address
// in case they are not present in a .env file
const GQL_URL = process.env.GQL_URL || '/api/v1';
const SERVE_PORT = process.env.GQL_PORT || 3000;
const CORS_ADDRESS = process.env.CORS_ADDRESS || '*';

// Basic express setup with logging
const app = express();
app.use(
  cors({
    origin: CORS_ADDRESS,
  }),
);
app.use(morgan('tiny'));

// Plug schema and resolvers into Apollo server
const server = new ApolloServer({
  typeDefs: schema,
  resolvers,
});

// Serve static HTML from the public folder in the root of the application
// this is where Gatsby build puts the final output files
app.use(express.static('public'));

// Requests to the GraphQL/Apollo URL should go to Apollo
server.applyMiddleware({ app, path: GQL_URL });

// Redirect any routes that aren't specified above this line to /404
app.get('*', function (req, res) {
  res.redirect('/404');
});

// Start the server!
app.listen({ port: SERVE_PORT }, () => {
  console.log('Server running on http://localhost:' + SERVE_PORT);
});

At this point, you can run node -r esm server/index from the terminal, and have a working Apollo server. Pointing your browser to http://localhost:3000 will give you Cannot GET /, that’s fine for now as we haven’t build the client side. But if you visit http://localhost/api/vi, you will be greeted with the GraphQL Playground.

Try it out by entering this on the left side of the window and hitting the play button:

mutation {
  signUp(
    firstName: "Some"
    lastName: "Body"
    email: "SomeBody@SomeWhere.tld"
  ) {
    result
  }
}

You should see the result on the right side Your subscription has been submitted, thank you!, and if you hit play again you should see This Email address is already subscribed!, you will also notice that this data has been inserted into your MongoDB database, just as we had planned! Now lets make that work with Gatsby.

Gatsby Client

src/apollo/client.js

First lets create a new folder apollo within the src directory, and within that a new file called client.js, here we will define the connection to the Apollo server we just built.

import ApolloClient from 'apollo-boost';
import fetch from 'isomorphic-fetch';

// Get the URI for Apollo backend from .env file or use localhost
const API_URI =
  process.env.GATSBY_API_URI || 'http://localhost:3000/api/v1';

// Export the Apollo connection
export const client = new ApolloClient({
  uri: API_URI,
  fetch,
});

gatsby-ssr.js and gatsby-browser.js

In order to use Apollo in Gatsby, we also need to wrap the whole app in the Apollo provider. We do this in both gatsby-ssr.js and gatsby-browser.js, using the same exact code:

import React from 'react';
import { ApolloProvider } from 'react-apollo';
import { client } from './src/apollo/client';

export const wrapRootElement = ({ element }) => (
  <ApolloProvider client={client}>{element}</ApolloProvider>
);

gatsby-config.js

We also need to source our .env file on the client side, so put this at the very top of gatsby-config.js:

require('dotenv').config({
  path: `.env`,
});

This goes at the top, you don’t need to change the rest of this file.

src/pages/subscribe.js

Now lets tie it all together with a sign up page:

Note: Again, this is a basic example, there is no form validation here but it would be wise to implement.

import React, { useState } from 'react';
import { Link } from 'gatsby';
import { Mutation } from 'react-apollo';
import gql from 'graphql-tag';

import Layout from '../components/layout';
import SEO from '../components/seo';

// Just like on the server, we need to define what our data model
// looks like, this needs to match our server or there will be errors
const SUBSCRIBE_MUTATION = gql`
  mutation SignUp(
    $firstName: String!
    $lastName: String!
    $email: String!
  ) {
    signUp(firstName: $firstName, lastName: $lastName, email: $email) {
      result
    }
  }
`;

const SubscriptionPage = () => {
  // These state variables will hold the form values when it is filled out
  const [firstNameValue, setFirstNameValue] = useState('');
  const [lastNameValue, setLastNameValue] = useState('');
  const [emailValue, setEmailValue] = useState('');
  return (
    <Layout>
      <SEO title='Subscription Page' />
      <h1>Subscribe to the Mailing List</h1>
      <p>Please fill out the form below:</p>

      {/* Wrap the whole form in a Mutation component */}
      <Mutation mutation={SUBSCRIBE_MUTATION}>
        {/*
        We are passing in the signUp mutation as defined earlier
        and passing the values of loading, error, and data in respect
        to that mutation.
      */}
        {(signUp, { loading, error, data }) => (
          <React.Fragment>
            <form
              onSubmit={async (event) => {
                // when the form is submitted, we will run the signUp function passed down
                // the Mutation component, and pass it the form values from the state variables
                event.preventDefault();
                signUp({
                  variables: {
                    firstName: firstNameValue,
                    lastName: lastNameValue,
                    email: emailValue,
                  },
                });
              }}
            >
              {/* Here we create a standard form using our state variables as the field values */}
              <div style={{ padding: '20px' }}>
                <label htmlFor='firstNameInput'>First Name: </label>
                <input
                  id='firstNameInput'
                  value={firstNameValue}
                  onChange={(event) => {
                    setFirstNameValue(event.target.value);
                  }}
                />
              </div>
              <div style={{ padding: '20px' }}>
                <label htmlFor='lastNameInput'>Last Name: </label>
                <input
                  id='lastNameInput'
                  value={lastNameValue}
                  onChange={(event) => {
                    setLastNameValue(event.target.value);
                  }}
                />
              </div>
              <div style={{ padding: '20px' }}>
                <label htmlFor='emailInput'>Email Address: </label>
                <input
                  id='emailInput'
                  value={emailValue}
                  onChange={(event) => {
                    setEmailValue(event.target.value);
                  }}
                />
              </div>
              <div style={{ padding: '20px' }}>
                <button type='submit'>Subscribe!</button>
              </div>
            </form>
            {/* Lastly, we add some information if the request is loading, errored, or completed */}
            <div style={{ padding: '20px' }}>
              {loading && <p>Loading...</p>}
              {error && (
                <p>
                  An unknown error has occured, please try again
                  later...
                </p>
              )}
              {data && <p>{data.signUp.result}</p>}
            </div>
          </React.Fragment>
        )}
      </Mutation>

      <Link to='/'>Go back to the homepage</Link>
    </Layout>
  );
};

export default SubscriptionPage;

src/pages/index.js

Just to be thorough, let’s add a link to the subscribe page from the index page, here is the whole file:

import React from 'react';
import { Link } from 'gatsby';

import Layout from '../components/layout';
import Image from '../components/image';
import SEO from '../components/seo';

const IndexPage = () => (
  <Layout>
    <SEO title='Home' />
    <h1>Hi people</h1>
    <p>
      <Link to='/subscribe/'>Subscribe to our Mailing List!</Link>
    </p>
    <p>Welcome to your new Gatsby site.</p>
    <p>Now go build something great.</p>
    <div style={{ maxWidth: `300px`, marginBottom: `1.45rem` }}>
      <Image />
    </div>
    <Link to='/page-2/'>Go to page 2</Link>
  </Layout>
);

export default IndexPage;

Lets run npm install --global concurrently nodemon, concurrently will allow us to run multiple commands (server and client) from the same NPM script, nodemon as you probably already know will listen for file changes and restart the running process automatically so you don’t have to.

In the package.json file, edit the develop line under scripts so it looks like this:

"develop": "concurrently \"nodemon -r esm server/index.js\" \"gatsby develop\"",

Now run npm run develop from the command line, and point your browser to http://localhost:8000/subscribe. Punch in a name and Email and hit the subscribe button, you should see Your subscription has been submitted, thank you! pop up below the form, and you should also have the corresponding data in your MongoDB Database.

Wrap Up

All that’s left to do wrap it up with a nice little bow on top for deployment. Remember all those .env checks we used? This is where we need them, make a new file in the root of the project called, you guessed it, .env.

.env

NODE_ENV=production
GQL_URL=/api/v1
GATSBY_API_URI=http://localhost:3000/api/v1
SERVE_PORT=3000
CORS_ADDRESS=http://localhost:3000
DATABASE_URL=mongodb://localhost:27017/subscriptionexample

You will obviously use different values when actually deploying, but these will work for our local example.

In the package.json file, edit the serve line under scripts, to look like this:

"serve": "nodemon -r esm server/index.js",

We will only run the express server in production since it can serve both the Apollo API and the static Gatsby build that makes up the client side. Run npm run build and then npm run serve from the command line. Now you can visit http://localhost:3000/subscribe/ (note the port change) to fill out and test your form, you will also note that the requests to the Gatsby frontend are logged in the console in addition to the Apollo API request.

Note: If you are working on multiple projects that serve a Gatsby frontend via Express static, you may find that when switching between them on localhost causes the index page to not load due to MIME-Type issues, the solution is to DELETE the public folder and re-run npm run build. If you switch back and forth a lot it’s probably easier just to run them on different ports.

Bonus Points

I know what you’re thinking, “What about Docker!?”, well I’ve got you covered. You’ll need to set up a MongoDB instance on your Docker host in a user-defined bridge network, and give it a static IP address.

Dockerfile

Make a new Dockerfile at the root of the project folder:

FROM node:10-alpine

ENV NODE_ENV=production
ENV GQL_URL=/api/v1
ENV GATSBY_API_URI=/api/v1
ENV SERVE_PORT=3000
ENV CORS_ADDRESS=https://yourdomain.com
ENV DATABASE_URL=mongodb://172.16.32.7:27017/subscribers

COPY ./ /opt/app
RUN apk add --no-cache --virtual .gyp python make g++
RUN npm install --global gatsby-cli

WORKDIR /opt/app
RUN npm install
RUN npm run build

EXPOSE 3000/tcp

CMD npm run serve

Note: You’ll need to change the CORS_ADDRESS and DATABASE_URI env declarations to fit your scenario

There you have it, deploy away!

Sponsors