In this article I will show you how you can extend a Gatsby static-site to manage memberships and paid subscriptions using Netlify and Paddle, with Fauna’s cloud API as the glue that keeps everything together.
We will be covering the following:
- Setting up a basic Gatsby site
- Configuring and deploying to Netlify
- Membership management using Netlify Identity
- Adding login and signup modals using React hooks
- Adding Gatsby private routes
- Creating subscription plans using Paddle
- Subscription management from members’ accounts using Fauna
You can find all of the finished code for the project here along with a working demo here.
Before we get started, let’s do a quick visual tour of what we will building:
Creating your Gatsby site
To begin with, we need a basic Gatsby site to build from.
We will start by installing the Gatsby CLI (Command Line Interface) by running the command:
npm install -g gatsby-cli
Next we will spin up a Gatsby instance using their default Gatsby Starter Blog starter. To do this, just go to the folder you want to install in and run:
gatsby new <your-project-name> https://github.com/gatsbyjs/gatsby-starter-blog
Now if you run gatsby develop
you can navigate to http://localhost:8000/
in your browser and see your site running on a development server.
Deploying to Netlify
With our Gatsby site up and running our next step is to deploy it to Netlify so that we can take advantage of Netlify’s serverless functions and identity storage. (if you don’t yet have a Netlify account you can create one for free here).
We will be deploying on Netlify directly from Github, so now is a good time to initialize your repository if you haven’t yet already. Now we will add a short netlify.toml
file in our root directory that will tell Netlify where we are going to be keeping our serverless functions:
[build]
command = "NODE_ENV=production gatsby build"
publish = "public/"
functions = "./functions"
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/:splat"
status = 200
Here we are simply telling Netlify that on build, any serverless functions we create will be in the ./functions
folder and they will be served from the endpoint www.mysite/api/
.
Now if we follow the Netlify instructions to link our Git repository, you will have a fully deployed site on Netlify that will be automatically rebuilt on any new commits and will be ready for our serverless functions that we will create in a little while.
While we are in Netlify, let’s also enable Netlify Identity which we will also be using soon. To do this, go to the Netlify dashboard and select the site that we just created. Then go to Identity and click “Enable Identity.”
Finally, we will install the Netlify plugin for Gatsby that will handle some configuration automatically for us when it comes to our private routes. To do that we will simply run npm install --save gatsby-plugin-netlify
and add it to our gatsby-config.js
file.
With that, we are all done with our Netlify configuration. Note, from now on, if we use the command netlify dev
instead ofgatsby develop
, we will be able to see changes to our serverless functions in real-time. So I recommend that you use that as you are following along.
Member management with Netlify Identity
Let’s now move on to how we can use Netlify’s Identity to manage our members. To help us with this, we will use a Gatsby plugin called gatsby-plugin-netlify-identity-gotrue
which is a wrapper for another package called react-netlify-identity-gotrue
.
GoTrue is an open-source API maintained by Netlify that allows you to access Netlify’s identity membership signup and login features. These two packages directly integrate GoTrue into React and Gatsby allow us to easily use Netlify Identity in our project.
Let’s start by getting both of these installed with:
npm install --save gatsby-plugin-netlify-identity-gotrue react-netlify-identity-gotrue.
Next let’s add the plugin to our gatsby-config.js:
{
resolve: `gatsby-plugin-netlify-identity-gotrue`,
options: {
url: `https://YOUR-NETLIFY-APP-URL`
}
},
Now we have access to a variety of helpful functions along with the identity context of any logged in user. Let’s start using them by creating our user login process, beginning with some login buttons that will control our signup modals using React Hooks.
First we will create a simple button.js
component in src/components
:
import React from 'react';
import { handleKeyDown } from '../utils/utils';
const Button = ({ children, showPopup, setShowPopup }) => {
const handleLoginClick = () => {
setShowPopup(!showPopup);
}
const handleLoginKeyDown = (e) => {
handleKeyDown(e, showPopup, setShowPopup);
}
return (
<button
onClick={ (e) => handleLoginClick() }
onKeyDown={ (e) => handleLoginKeyDown(e) }
>{children}</button>
)
}
export default Button;
And then in a new folder src/utils
we will create a file called utils.js
and add this:
export const handleKeyDown = (ev, showPopup, setShowPopup) => {
if (ev.keyCode === 13 && !showPopup) {
// enter to open
setShowPopup(true);
} else if (ev.keyCode === 27 && showPopup) {
// escape to close
setShowPopup(false);
}
}
In the component we are adding the ability for the button to control React Hooks that we will pass into it in order to determine whether or not our Signup/Login modal will be displayed. While the handleKeyDown
function is checking for keyboard enter and escape events and setting showPopup
to true
or false
respectively. We put the handleKeyDown
function into a separateutills.js
file since we will be using the same function later when we create our modal in order to let users close the modal using the escape key.
Next, let’s move on to create a new component file src/components/loginButtons.js:
import React from 'react';
import { Link } from "gatsby";
import { useIdentityContext } from 'react-netlify-identity-gotrue';
import Button from './button';
const LoginButtons = ({ children, showRegisterPopup, setShowRegisterPopup, showLoginPopup, setShowLoginPopup }) => {
const identity = useIdentityContext();
return (
<>
{!identity.user &&
<>
<div className="headerButton">
<Button showPopup={showRegisterPopup} setShowPopup={setShowRegisterPopup}>Sign Up</Button>
<Button showPopup={showLoginPopup} setShowPopup={setShowLoginPopup}>Login</Button>
</div>
</>
}
{identity.user &&
<>
<div className="headerButton">
<button
><Link to="/account/profile">Profile</Link></button>
<button
className="link"
onClick={identity.logout}
onKeyDown={identity.logout}
>Logout</button>
</div>
</>
}
</>
)
}
export default LoginButtons;
At the same time, we will also make a few additions to our src/components/layout.js
file:
import React, { useState } from 'react';
import { Link } from "gatsby"
import LoginButtons from './loginButtons';
const Layout = ({ location, title, children }) => {
const rootPath = `${__PATH_PREFIX__}/`;
const isRootPath = location.pathname === rootPath
let header
const[showRegisterPopup, setShowRegisterPopup] = useState(false);
const[showLoginPopup, setShowLoginPopup] = useState(false);
if (isRootPath) {
header = (
<>
<h1 className="main-heading">
<Link to="/">{title}</Link>
</h1>
<LoginButtons showRegisterPopup={showRegisterPopup} setShowRegisterPopup={setShowRegisterPopup} showLoginPopup={showLoginPopup} setShowLoginPopup={setShowLoginPopup} />
</>
)
} else {
header = (
<>
<Link className="header-link-home" to="/">
{title}
</Link>
<LoginButtons showRegisterPopup={showRegisterPopup} setShowRegisterPopup={setShowRegisterPopup} showLoginPopup={showLoginPopup} setShowLoginPopup={setShowLoginPopup} />
</>
)
}
In our component, we are initializing our Netlify Identity context helper. With this we can check if a user is logged in by checking if identity.user
exists. If it doesn’t, we will show two buttons: one for signing up and one for logging in.
These buttons will toggle the state that is controlled in the layout.js
file using the React Hooks showRegisterPopup
and showLoginPopup
. We will manage this state from the Layout component since we will also have to pass these hooks into the modals themselves so we can close them.
When a user is logged in, we will show him a link to his profile page (we will create this a bit later using Gatsby private routes), as well as the option to log out. Using our Netlify Identity context helper we can simply call identity.logout
which will handle all the backend of the logout process for us.
Now we need to create our actual modal, which we will do in two parts: First, the popup itself in a new component called popup.js
and after that the form component that we will call registerForm.js
which will be served through the popup and will be used to collect sign up and login information.
Let’s look at the popup modal first:
import React from 'react';
import { X } from 'react-feather';
import { handleKeyDown } from '../utils/utils';
import './styles/popup.css';
const Popup = ( { children, showPopup, setShowPopup } ) => {
return (
<>
{showPopup ? (
<div className="popup-overlay">
<div
className="popup-background"
onClick={ (e) => setShowPopup(false) }
onKeyDown={ (e) => handleKeyDown(e, showPopup, setShowPopup) }
tabIndex={0}
aria-label="Toggle Popup"
role="button"
></div>
<div className="popup-inner">
<X
className="popup-close"
onClick={ (e) => setShowPopup(false) }
onKeyDown={ (e) => handleKeyDown(e, showPopup, setShowPopup) }
tabIndex={0}
aria-label="Toggle Popup"
role="button"
/>
{children}
</div>
</div>
) : null}
</>
)
}
export default Popup;
First, note that we are importing a custom styles file here as well as the icons package react-feather
. You can find the CSS file in the source code for the example here and simply copy and paste it into your project, while react-feather
can be installed with:
npm install --save react-feather
Styling aside, you will see that we are passing in the React hook showPopup
which will control whether or not anything is actually returned. Then you will see that if someone clicks (or presses the escape key) on either the background overlay of the modal or the X icon, showPopup
will be set to false and the modal will be hidden again. Finally, you will see that we pass in children to allow us to display our form inside the popup.
Let’s create the component for that form in src/components/registerForm.js
:
import React, { useState } from 'react';
import { useIdentityContext } from 'react-netlify-identity-gotrue';
const RegisterForm = ({ children, loginType, setShowPopup }) => {
const identity = useIdentityContext();
const [formValues, setFormValues] = useState({
email: '',
password: ''
});
const handleEmailInputChange = (event) => {
setFormValues((values) => ({
...values,
email: event.target.value,
}));
};
const handlePasswordInputChange = (event) => {
setFormValues((values) => ({
...values,
password: event.target.value,
}));
};
const handleSubmit = (e) => {
e.preventDefault();
if (loginType === 'login') {
handleLogin(formValues);
}
else {
handleSignup(formValues);
}
}
const handleSignup = async ( formValues ) => {
let newUser = {
email: formValues.email,
password: formValues.password
}
await identity.signup(newUser)
.then(() => {
console.log('registration successful');
setShowPopup(false);
})
.catch((e) => {
console.log(e);
});
}
const handleLogin = async ( formValues ) => {
await identity.login({
email: formValues.email,
password: formValues.password
})
.then(() => {
setShowPopup(false);
})
.catch((e) => {
console.log(e);
});
}
return (
<div className="member-form">
<form onSubmit={handleSubmit}>
<div className="member-form-group">
<label htmlFor="username">Email</label>
<input type="email" className='member-input' placeholder="youremail@example.com" value={formValues.email} onChange={handleEmailInputChange} />
</div>
<div className="member-form-group">
<div>
<label htmlFor="password">Password</label>
<input type="password" className='member-input' placeholder="Password" value={formValues.password} onChange={handlePasswordInputChange} />
</div>
</div>
<div className="member-form-group" style={{textAlign: 'center', paddingTop: '2rem'}}>
<button className="button member-submit" type="submit">
<span className="button-content">{loginType ==='login' ? 'Login' : 'Register'}</span>
</button>
</div>
</form>
</div>
)
}
export default RegisterForm;
Here we are using helper functions from our Netlify Identity context in order to submit our form data. Let’s leave those to the side for a minute while we see how the form itself is actually working.
We have a React hook called formValues
which is initiated with empty values for “email” and “password.” Whenever one of the inputs on the form changes, these values are updated via setFormValues
which is called from both handleEmailInputChange
and handlePasswordInputChange
. Finally, when the form is submitted the function handleSubmit
is called which checks to see whether this is a signup or login form based on the property we have passed in called loginType.
Now let’s look at where we use the Netlify helper functions. First, we can see in handleSignup
that we are getting the form values and storing them in an object that has an “email” and a “password” key. Then we simply need to provide this object to identity.signup()
and Netlify automatically logs the user in for us. (as well as creating a cookie that persists the login state) We use await
here so that when the function returns successfully we can close the popup modal automatically by calling setShowPopup(false)
.
If we look at handleLogin
, we see that it is very similar. The only difference is that now we are calling identity.login()
instead. One thing to note is that since we are using await
, both of these functions need to be async
functions.
Finally, we just need to wire up our popup modal and form in our layout.js
file. Here’s the updated version:
import React, { useState } from 'react';
import { Link } from "gatsby"
import Popup from './popup';
import RegisterForm from './registerForm';
import LoginButtons from './loginButtons';
const Layout = ({ location, title, children }) => {
const rootPath = `${__PATH_PREFIX__}/`;
const isRootPath = location.pathname === rootPath
let header
const[showRegisterPopup, setShowRegisterPopup] = useState(false);
const[showLoginPopup, setShowLoginPopup] = useState(false);
header = (
<>
{isRootPath ?
<h1 className="main-heading">
<Link to="/">{title}</Link>
</h1>
:
<Link className="header-link-home" to="/">
{title}
</Link>
}
<LoginButtons showRegisterPopup={showRegisterPopup} setShowRegisterPopup={setShowRegisterPopup} showLoginPopup={showLoginPopup} setShowLoginPopup={setShowLoginPopup} />
<Popup showPopup={showRegisterPopup} setShowPopup={setShowRegisterPopup} >
<h1 style={{textAlign: 'center', paddingBottom: '2rem'}}>Join now</h1>
<RegisterForm loginType='register' setShowPopup={setShowRegisterPopup} />
</Popup>
<Popup showPopup={showLoginPopup} setShowPopup={setShowLoginPopup} >
<h1 style={{textAlign: 'center', paddingBottom: '2rem'}}>Sign in to access your account</h1>
<RegisterForm loginType='login' setShowPopup={setShowLoginPopup} />
</Popup>
</>
)
return (
<div className="global-wrapper" data-is-root-path={isRootPath}>
<header className="global-header">{header}</header>
<main>{children}</main>
<footer>
© {new Date().getFullYear()}, Built with
{` `}
<a href="https://www.gatsbyjs.com">Gatsby</a>
</footer>
</div>
)
}
export default Layout;
First, note that I’ve refactored the code of the original template in order to avoid duplication in the header component. Instead of the previous If / else
statement, I’ve put a conditional statement directly in the HTML to control what is displayed when isRootPath
is true.
Then after that we simply import our Popup and RegisterForm components and pass in the React hooks for signing up and logging in, as well as a title for each popup. Our finished popup will now look like this:
Gatsby Private Routes
Next we will set up private routes in Gatsby that will allow us to have a private account management section of the site for each user.
Essentially, a private route in Gatsby is a route that is only generated on client-side when certain conditions are met. (rather than the majority of a Gatsby site, or any other static site for that matter, that will be generated in advance on the server side) This prevents someone who is not entitled to see some content from being served it along with all of the other resources of a site. You can read more about it here.
There’s also a great tutorial on how to get this setup using Auth0 instead of Netlify Identity over on the Auth0 blog. (credit to the author Sam Julien, as a lot of the following code was adapted from his examples)
To get this set up, we first need to edit our gatsby-node.js
file in the root of our project. We will be adding the following code to what’s already there:
exports.onCreatePage = async ({ page, actions }) => {
const { createPage } = actions
if (page.path.match(/^\/account/)) {
page.matchPath = "/account/*"
createPage(page)
}
}
What this is doing is it is telling Gatsby to look for any pages that fall under the path /account/
and that these pages should be rendered on the client-side only. (rather than generated as static content on the server side.
With that done, we can now create our account page as a new file in the page directory called account.js
:
import React from "react"
import { Link, graphql } from "gatsby"
import { Router } from "@reach/router"
import Layout from "../components/layout"
import Profile from "../components/profile"
import PrivateRoute from "../components/privateRoute"
const Account = ({ data, location }) => {
const siteTitle = data.site.siteMetadata?.title || `Title`
return (
<Layout location={location} title={siteTitle}>
<Router>
<PrivateRoute path="/account/profile" component={Profile} />
</Router>
</Layout>
)
}
export default Account
export const pageQuery = graphql`
query {
site {
siteMetadata {
title
}
}
allMarkdownRemark(sort: { fields: [frontmatter___date], order: DESC }) {
nodes {
excerpt
fields {
slug
}
frontmatter {
date(formatString: "MMMM DD, YYYY")
title
description
}
}
}
}
`
Most of this is boilerplate and copied from our index.js
page, including the GraphQL query and the metadata that is passed into our Layout component. The important thing to notice here is that we are using Router imported from @reach/router
(no NPM install required since it comes by default with Gatsby) to wrap a new component called PrivateRoute that we will create now.
So let’s create a new file in our components folder called privateRoute.js
:
import React from "react";
import { useIdentityContext } from 'react-netlify-identity-gotrue';
const PrivateRoute = ({ component: Component, path, ...rest }) => {
const identity = useIdentityContext();
return identity.user ? <Component {...rest} /> : null;
}
export default PrivateRoute;
This is a super simple and straight to the point component. We are importing our Netlify Identity context and then using that, we are returning a component if a user is logged in and null if they are not.
So essentially this component serves as a wrapper for another component that we specify and only serves that component if a user is logged in.
If you scroll back up to our account.js
page, you will see that the component we are wrapping here is called Profile. So let’s create a new component file called profile.js
and in it we will put:
import React from "react"
import { useIdentityContext } from 'react-netlify-identity-gotrue';
const Profile = () => {
const identity = useIdentityContext();
if (!identity.user) {
return <div>Loading...</div>;
}
return(
<>
<section className="blog-wrapper" style={{textAlign: 'center'}}>
<h2>Account Details</h2>
<p>Email: {identity.user.email}</p>
</section>
</>
)
}
export default Profile;
This component is the actual content that will be served to a logged in user browsing to /account/profile/
. Here we are using our Identity context to check if a user’s details are available, if not they are shown a simple loading message. Then once the logged in user’s details have been loaded, the profile page simply displays their email address. (which is the only information we have collected from them in this simple example)
And with that, we have a basic but fully functioning member sign up and login flow. Let’s look at how we can add paid subscriptions to that next using Paddle and Fauna.
Creating subscription plans using Paddle
Next we will look at how we can let our users manage subscription plans using their account profiles that we just created. To manage our subscriptions, we will use a service called Paddle.
Essentially Paddle takes care of all of the billing and invoicing issues that come with selling a SaaS product and it has an easy-to-use dashboard as well as lots of API functionality that makes it very developer friendly.
For the purposes of our example, we will create a new Paddle sandbox account here.
Once we have our account, we will create a new Subscription Plan by going to Catalog > Subscription Plans. Create a new plan and give it whatever name and price you like - just take note of the Plan ID that is generated once you create the plan.
Now let’s get Paddle connected to our Gatsby build. There is a helpful plugin called gatsby-plugin-paddle
that injects Paddle’s script into your site automatically, but unfortunately it doesn’t support Paddle’s sandbox environment. So instead of using the plugin, I have taken the code from this plugin, modified it slightly and we will use it directly in our project.
To do that, we will create a new file in our root folder called gatsby-ssr.js
and inside it we will paste:
const React = require(`react`);
exports.onRenderBody = ({ setPostBodyComponents }) => {
if (process.env.NODE_ENV === `production`) {
return setPostBodyComponents([
<script key="gatsby-plugin-paddle" src="https://cdn.paddle.com/paddle/paddle.js" />,
<script
key={`gatsby-plugin-paddle-vendor`}
type="text/javascript"
dangerouslySetInnerHTML={{
__html: `Paddle.Setup({ vendor: ${process.env.PADDLE_ID} });`
}}
/>
]);
}
else {
return setPostBodyComponents([
<script key="gatsby-plugin-paddle" src="https://cdn.paddle.com/paddle/paddle.js" />,
<script
key={`gatsby-plugin-paddle-vendor`}
type="text/javascript"
dangerouslySetInnerHTML={{
__html: `Paddle.Environment.set('sandbox'); Paddle.Setup({ vendor: ${process.env.PADDLE_ID} });`
}}
/>
]);
}
};
We will also need to create a .env.development
file in our root directory and put inside of that:
PADDLE_ID=YOUR_VENDOR_ID
PADDLE_PRODUCT_ID=YOUR_PADDLE_PRODUCT_ID
The gatsby-ssr.js
file is hooking into Gatsby’s build process and every time a page is generated it injects Paddle’s script into the body of that page. The Paddle script requires your Vendor ID which you can find in the Paddle dashboard under Developer Tools > Authentication. We are storing that as an .env variable since it should be kept secret and not logged into your source control.
The original plugin only added the Paddle script to production builds, so I modified the code to set Paddle to sandbox mode if you are not running in production. This way, you could create an .env.production
file and put your Paddle production Vendor ID there and be able to simultaneously test with the sandbox while you are in dev mode and then deploy smoothly to production.
With that configuration out of the way, let’s look at how we can actually use Paddle in our site. Let’s open up our utils.js
file and add the following:
export const openPaddleCheckout = (user) => {
let options = {product: process.env.PADDLE_PRODUCT_ID};
if(user && user.email) {
options.email = user.email;
}
let Paddle = window['Paddle'];
if (Paddle) {
Paddle.Checkout.open(options);
}
}
Here we are creating a utility function that we can call from anywhere in our app, that will open up a new Paddle checkout session and serve it as an overlay to our site. Using Paddle’s helper functions that were set up with our script injection previously, we just need to define some options and pass these into Paddle.Checkout.open()
.
For the options, we are pulling the Paddle Production ID from our .env.development
file and then we are checking if we have an email passed in with our user object comes from Netlify Identity. If we have an email, this will be passed into our options which tells Paddle to pre-populate the email address so the user doesn’t have to type it in again.
Finally, we will check that the Paddle script is available and loaded using window['Paddle']
before we actually call it.
Now let’s go back to our index.js file in order to use our Paddle checkout. First, we will make some additions to the top half of the code:
import React, { useState, useEffect } from 'react';
import { Link, graphql } from "gatsby";
import { useIdentityContext } from 'react-netlify-identity-gotrue';
import Bio from "../components/bio";
import Layout from "../components/layout";
import Seo from "../components/seo";
import Button from "../components/button";
import Popup from "../components/popup";
import RegisterForm from "../components/registerForm";
import { openPaddleCheckout } from "../utils/utils";
const BlogIndex = ({ data, location }) => {
const siteTitle = data.site.siteMetadata?.title || `Title`;
const posts = data.allMarkdownRemark.nodes;
const identity = useIdentityContext();
const[showPaddleCheckout, setShowPaddleCheckout] = useState(false);
useEffect(() => {
if (identity.user && showPaddleCheckout) {
openPaddleCheckout(identity.user);
setShowPaddleCheckout(false);
}
}, [identity.user, showPaddleCheckout])
…
Here we are importing our openPaddleCheckout
function as well as React’s useEffect
. We will also create a React hook called showPaddleCheckout.
Next we will create a useEffect
that checks if there is a logged in user and if our showPaddleCheckout
hook is set to true. If both of these are the case, then we will call our openPaddleCheckout
function while passing in our user object from Netlify Identity. Finally, we will call setShowPaddleCheckout
and set it to false, so that we don’t initiate another checkout session if the page is refreshed.
Note that we have passed an array of dependencies to our useEffect
that includes identity.user
and showPaddleCheckout
. This means that if the value of either of these two dependencies changes the useEffect
will be called again.
Now we just have to add a button where we can set the value of our showPaddleCheckout
hook. Let’s add it right beneath where we are showing all of our blog posts on the index.js
page:
return (
<Layout location={location} title={siteTitle}>
<Seo title="All posts" />
<Bio />
<ol style={{ listStyle: `none` }}>
{posts.map(post => {
const title = post.frontmatter.title || post.fields.slug
return (
<li key={post.fields.slug}>
<article
className="post-list-item"
itemScope
itemType="http://schema.org/Article"
>
<header>
<h2>
<Link to={post.fields.slug} itemProp="url">
<span itemProp="headline">{title}</span>
</Link>
</h2>
<small>{post.frontmatter.date}</small>
</header>
<section>
<p
dangerouslySetInnerHTML={{
__html: post.frontmatter.description || post.excerpt,
}}
itemProp="description"
/>
</section>
</article>
</li>
)
})}
</ol>
{
identity.user &&
<>
<h1>Add Subscription</h1>
<Button showPaddleCheckout={showPaddleCheckout} setShowPaddleCheckout={setShowPaddleCheckout} user={identity.user}>Subscribe</Button>
</>
}
</Layout>
)
After the list of posts, we are checking if a user is logged in then simply serving a button with some header text.
Notice though that we will need to update our button.js
component to handle this new case of adding a subscription. So we will modify our code to the following:
import React from 'react';
import { handleKeyDown } from '../utils/utils';
const Button = ({ children, showPopup, setShowPopup, showPaddleCheckout, setShowPaddleCheckout, user }) => {
const handleLoginClick = () => {
if (!user) {
setShowPopup(!showPopup);
}
if (setShowPaddleCheckout && !showPaddleCheckout) {
setShowPaddleCheckout(true);
}
}
const handleLoginKeyDown = (e) => {
if (!user) {
handleKeyDown(e, showPopup, setShowPopup);
}
if (setShowPaddleCheckout && !showPaddleCheckout) {
setShowPaddleCheckout(true);
}
}
return (
<button
onClick={ (e) => handleLoginClick() }
onKeyDown={ (e) => handleLoginKeyDown(e) }
>{children}</button>
)
}
export default Button;
We are now passing in showPaddleCheckout
, setShowPaddleCheckout
and our Netlify user
as props. In both handleLoginClick
and handleLoginKeyDown
we are first checking if we have a prop for setShowPaddleCheckout
and that showPaddleCheckout
is not already set to true. Then we are calling setShowPaddleCheckout(true)
which will then trigger our useEffect
to serve our Paddle checkout.
Subscription management from member accounts using Fauna
Finally, let’s tie everything together using Fauna. Credit to Jason Lengstorf and his great article on connecting Stripe to Netlify using Fauna that formed the basis of a lot of the following code.
If you haven’t yet already, head over to fauna.com and get signed up for a free account. Then you will need to create a new database, give it whatever name you want.
Once you have the database created, we will need to define its schema. I’m following Jason’s advice here and storing our schema in a new db
folder in our project’s root directory. Call this file schema.gql
and paste in the following:
type User {
netlifyID: ID!
paddleSubID: ID!
}
type Query {
getUserByNetlifyID(netlifyID: ID!): User!
}
This is telling our Fauna instance that our database will have a data type of User
that will have two ID types: netlifyID
and paddleSubID
. We are also defining one query that will expect a netlifyID
and will return the User
object.
Now we will upload this into Fauna by going to GraphQL > Import Schema.
Next we need to generate a Server Key from Fauna that we can use to connect from our Gatsby site. On the left-hand menu go to Security and then New Key. Now we will save this key in our .env.development
file:
PADDLE_ID=YOUR_VENDOR_ID
PADDLE_PRODUCT_ID=YOUR_PADDLE_PRODUCT_ID
FAUNA_SERVER_KEY=YOUR_SERVER_KEY
As a last configuration step, we will install the node-fetch
package to our project so that we can use fetch in our Netlify Serverless functions. We will need to use Serverless functions to connect to our Fauna instance, since we don’t want to expose our secret key in our client-side code.
So we will run:
npm install --save node-fetch
Now with that set up, we will start writing our Netlify serverless functions. Let’s start with a new file in functions/utils/fauna.js
:
const fetch = require('node-fetch');
exports.faunaFetch = async ({ query, variables }) => {
return await fetch('https://graphql.fauna.com/graphql', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.FAUNA_SERVER_KEY}`,
},
body: JSON.stringify({
query,
variables,
}),
})
.then((res) => res.json())
.catch((err) => console.error(JSON.stringify(err, null, 2)));
};
Credit to Jason again, as this was his creation in his tutorial. Here we are just creating a boilerplate fetch function that we will be able to reuse in order to access our Fauna instance. Note that the function expects query and variables objects and then converts these into JSON in the body of the request. Also note we are pulling our Fauna Server Key from our .env.development
file.
Now let’s use our faunaFetch
utility function in a new file functions/addSub.js
:
const { faunaFetch } = require('./utils/fauna');
exports.handler = async (event) => {
// Only allow POST
if (event.httpMethod !== 'POST') {
return {
statusCode: 200,
body: JSON.stringify('Method not allowed')};
}
const { netlifyID, paddleSubID } = JSON.parse(event.body);
// link the Netlify and Paddle Sub IDs in Fauna
await faunaFetch({
query: `
mutation ($netlifyID: ID!, $paddleSubID: ID!) {
createUser(data: { netlifyID: $netlifyID, paddleSubID: $paddleSubID }) {
netlifyID
paddleSubID
}
}
`,
variables: {
netlifyID: netlifyID,
paddleSubID: paddleSubID,
},
});
return {
statusCode: 200,
body: JSON.stringify('Sub Added')
};
};
In order to create a new record in our Fauna we will specify our mutation query that expects both a Netlify User ID and a Paddle Subscription ID. We will then be storing both of those in our Fauna database so we can link one to the other. That way, we can fetch our users by their subscription ID and vice versa.
Now we need to create a function on the client-side to call our serverless function to add a new user. Let’s go back to our src/utils/utils.js
file and add the following:
const addPaddleSubscription = ( netlifyID, paddleSubID ) => {
async function postData(url) {
const response = await fetch(url, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({netlifyID: netlifyID, paddleSubID: paddleSubID})
});
return response.json();
}
postData('/api/addSub/')
.then(data => {
console.log("Success: ", data);
})
.catch((error) => {
console.log('Error: ', JSON.stringify(error));
});
}
Here we are creating an async function that will call our serverless function at /api/addSub/
passing our Netlify ID and Paddle Subscription ID’s into the body.
Now we will just need to modify our openPaddleCheckout
function in our same file:
export const openPaddleCheckout = (user) => {
const checkoutComplete = (data) => {
var checkoutId = data.checkout.id;
Paddle.Order.details(checkoutId, function(data) {
addPaddleSubscription(user.id, data.order.subscription_id)
});
}
let options = {product: process.env.PADDLE_PRODUCT_ID, successCallback: checkoutComplete};
if(user && user.email) {
options.email = user.email;
}
let Paddle = window['Paddle'];
if (Paddle) {
Paddle.Checkout.open(options);
}
}
Here we are adding an additional option to our Paddle.Checkout.open
called successCallback
which runs after a payment is made successfully.
In our checkoutComplete
function we get the checkout ID that is passed to the callback as data.checkout.id
. Then with this ID, we can get further details from Paddle by calling Paddle.Order.details(checkoutId)
. Finally, this function also has a callback function where we will call addPaddleSubscription()
while passing in our Netlify User ID and the Paddle Subscription ID that we just accessed from the details via data.order.subscription_id
.
Now that we’ve linked our Netlify ID’s with Paddle subscriptions using Fauna, let’s build a way to display a Paddle subscription in a user’s profile. To do this, we will need to create another serverless function and accompanying client-side API call to get a subscription ID by Netlify user ID.
Let’s start with our serverless function we will create in a new file at functions/getSub.js
:
const { faunaFetch } = require('./utils/fauna');
exports.handler = async (event) => {
// Only allow POST
if (event.httpMethod !== 'POST') {
return {
statusCode: 200,
body: JSON.stringify('Method not allowed')};
}
try {
const { netlifyID } = JSON.parse(event.body);
const result = await faunaFetch({
query: `
query ($netlifyID: ID!) {
getUserByNetlifyID(netlifyID: $netlifyID) {
netlifyID
paddleSubID
_id
}
}
`,
variables: {
netlifyID: netlifyID,
},
});
return {
statusCode: 200,
body: JSON.stringify({subs: true, data: result.data.getUserByNetlifyID}),
};
}
catch (err) {
console.log(err); // output to netlify function log
return {
statusCode: 200,
body: JSON.stringify({subs: false, data: {} }),
};
}
};
Most of this function is the same as addSub
, we are just passing in different data and using a different Fauna query. Here we are parsing the Netlify ID via the body of our POST call and then using that in a Fauna query (rather than a mutation). We are calling the query that we previously defined in our schema (getUserByNetlifyID
) and we are asking that query to return the paddleSubID
as well as _id
which is the Fauna ID for the record in the database. (we will use this a bit later when we want to delete records)
Note the structure of the data that we are returning. We are returning an object that has a key “subs” that will either be true or false, depending on if any subs currently exist for that user ID and then we are returning the actual data under the key “data.”
Now that we have our query and serverless function ready, let’s create our POST request in src/utils/utils.js
by adding the following:
export const getPaddleSubscription = async ( netlifyID ) => {
async function postData(url) {
const response = await fetch(url, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({netlifyID: netlifyID})
});
return response.json();
}
return await postData('/api/getSub/');
}
This is a very straight-forward function that simply calls our new serverless function while passing in the Netlify ID and returns the response, which will be the Netlify ID, Paddle Subscription ID and Fauna ID.
Now let’s use this data in our user profile component. We will replace our src/components/profile.js
with the following:
import React, { useState, useEffect } from 'react';
import { useIdentityContext } from 'react-netlify-identity-gotrue';
import { getPaddleSubscription } from '../utils/utils';
const Profile = () => {
const identity = useIdentityContext();
const [loadingSubscription, setLoadingSubscription] = useState(true);
const [subscription, setSubscription] = useState(null);
useEffect(() => {
if (loadingSubscription) {
getPaddleSubscription(identity.user.id)
.then(newSub => {
if (newSub.subs) {
setSubscription(newSub.data);
}
setLoadingSubscription(false);
})
.catch(err => {
setLoadingSubscription(false);
})
}
}, [loadingSubscription, setSubscription, setLoadingSubscription, subscription, identity.user.id])
if (!identity.user) {
return <div>Loading...</div>;
}
return(
<>
<section className="blog-wrapper" style={{textAlign: 'center'}}>
<h2>Account Details</h2>
<p>Email: {identity.user.email}</p>
<h3>Subscriptions</h3>
{loadingSubscription && <p>Loading Subscriptions</p>}
{!loadingSubscription && subscription !== null &&
<p>Test Subscription: {subscription.paddleSubID} </p>
}
{!loadingSubscription && subscription === null &&
<p>No current subscriptions</p>
}
</section>
</>
)
}
export default Profile;
Here we have two React Hooks that are controlling the action: loadingSubscription
and subscription
. By default, loadingSubscription
is set to true
which will cause our useEffect
to be triggered when the component is initially loaded. In the useEffect
, we then call our getPaddleSubscription()
function passing in our user ID from our Netlify Identity context. Once that is returned, we will check if any subscription was found by checking if subs
is true
, then we set the subscription ID that is returned using setSubscription()
. Finally, whether a subscription was found or not, we will set loadingSubscription
to false
.
Note that we have the following as dependencies in our useEffect
: subscription, setSubscription, loadingSubscription, setLoadingSubscription, identity.user.id
. So if any of these change our useEffect
will run again, although it will not actually fetch new data unless loadingSubscription
is set to true
.
Then to display our data, we are using a few conditionals in our HTML. If loadingSubscription
is true
, we are simply displaying a message that says “Loading Subscriptions.” Then once loadingSubscription
is complete and set to false
, we have two options:
- If a subscription is found, it will be displayed with it’s Paddle ID. (You could do a lot more here by displaying other subscription data to your user, but for the purpose of this example we will just show the ID)
- If no subscription is found, it simply says “No current subscriptions”
Finally, let’s take things one step further and let our user’s cancel their subscription from their account profile.
First we will need an API key from Paddle which you can find in the Paddle Dashboard under Developer Tools > Authentication, then you can just use the Default API Key and press Reveal Auth Code. Paste this code into your .env.development
file:
PADDLE_ID=YOUR_VENDOR_ID
PADDLE_PRODUCT_ID=YOUR_PADDLE_PRODUCT_ID
FAUNA_SERVER_KEY=YOUR_SERVER_KEY
PADDLE_AUTH_CODE=YOUR_PADDLE_API_KEY
Now let’s create a new serverless utility function in functions/utils called paddleCancel.js
:
const fetch = require('node-fetch');
exports.paddleCancel = async (subID) => {
return await fetch('https://sandbox-vendors.paddle.com/api/2.0/subscription/users_cancel', {
method: 'POST',
body: new URLSearchParams({
'vendor_id': process.env.PADDLE_ID,
'vendor_auth_code': process.env.PADDLE_AUTH_CODE,
'subscription_id': subID
})
})
.then((res) => res.json())
.catch((err) => console.error(JSON.stringify(err, null, 2)));
};
Here we are calling the Paddle API (more details here) using fetch and passing in form encoded data in the body with three items: vendor_id
and vendor_auth_code
simply come from our .env
file while subscription_id
will be provided as an argument to our function.
Now let’s use this utility function in a new serverless function functions/deleteSub.js
:
const { faunaFetch } = require('./utils/fauna');
const { paddleCancel } = require('./utils/paddleCancel');
exports.handler = async (event) => {
// Only allow POST
if (event.httpMethod !== 'POST') {
return {
statusCode: 200,
body: JSON.stringify('Method not allowed')};
}
const { netlifyID } = JSON.parse(event.body);
const result = await faunaFetch({
query: `
query ($netlifyID: ID!) {
getUserByNetlifyID(netlifyID: $netlifyID) {
netlifyID
paddleSubID
_id
}
}
`,
variables: {
netlifyID: netlifyID,
},
});
const paddleResult = await paddleCancel(result.data.getUserByNetlifyID.paddleSubID);
console.log(paddleResult);
await faunaFetch({
query: `
mutation ($faunaID: ID!) {
deleteUser(id: $faunaID) {
netlifyID
paddleSubID
}
}
`,
variables: {
faunaID: result.data.getUserByNetlifyID._id,
},
});
return {
statusCode: 200,
body: JSON.stringify('Sub Deleted'),
};
};
In this function we have three important things happening one after the other:
- First, we are querying Fauna by Netlify ID and returning the Paddle Subscription ID as well as the Fauna ID. (same as we did in
getSub.js
) - Then we are using that Paddle Subscription ID to call our
paddleCancel
function. (we are just logging the response from Paddle in the example) - After that, we are calling Fauna with a new mutation that deletes our User data item
Note, this is why we needed to return our Fauna _id
before, since it is required in order to delete the item.
As with all of our serverless functions, we need to add an accompanying client-side API call, so let’s add that to our src/utils/utils.js
file:
export const cancelPaddleSubscription = ( netlifyID ) => {
async function postData(url) {
const response = await fetch(url, {
method: 'POST',
mode: 'cors',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({netlifyID: netlifyID })
});
return response.json();
}
postData('/api/deleteSub/')
.then(data => {
console.log("Success: ", data);
})
.catch((error) => {
console.log('Error: ', JSON.stringify(error));
});
}
Again, very straightforward - we are just passing our Netlify ID into the body and calling /api/deleteSub/
with a POST request.
Finally to get all this online we just need to update our UI, so let’s go back to our src/components/profile.js
and make a couple of quick changes:
import { getPaddleSubscription, cancelPaddleSubscription } from '../utils/utils';
…
return(
<>
<section className="blog-wrapper" style={{textAlign: 'center'}}>
<h2>Account Details</h2>
<p>Email: {identity.user.email}</p>
<h3>Subscriptions</h3>
{loadingSubscription && <p>Loading Subscriptions</p>}
{!loadingSubscription && subscription !== null &&
<p><span>Test Subscription: {subscription.paddleSubID} </span><button onClick={e => {cancelPaddleSubscription(identity.user.id); setSubscription(null); }}>Cancel</button></p>
}
{!loadingSubscription && subscription === null &&
<p>No current subscriptions</p>
}
</section>
</>
)
We are adding our new cancelPaddleSubscription
to our imports and then we are using it in the HTML that is served when a user has a subscription. Then we have put a simple button inside a span next to the Paddle Subscription ID which when clicked will call cancelPaddleSubscription
with the Netlify User ID as an argument. It will also set subscription
back to null
so the subscription will disappear as soon as the button is clicked.
And that’s it! We’ve now got a fully functioning account management service that let’s our users subscribe and cancel subscriptions on their own.
Summary
In this post, you learned how to:
- Set up a basic Gatsby site
- Configure and deploy it to Netlify
- Manage memberships using Netlify Identity
- Add login and signup modals using React hooks
- Add private member areas using Gatsby private routes
- Create subscription plans using Paddle
- Manage subscriptions from users’ accounts using Fauna
This post gives you the basic flow of how you can incorporate memberships and subscriptions into your static site and how a simple and flexible database like Fauna can help you pull it all together.
You can get even more ideas of what you can do with them together by checking out more of my work at Epilocal where I am building technology for local news and other small online publishers using this same tech stack to manage my memberships and SaaS subscriptions.
Written in connection with the Write with Fauna Program.
Top comments (0)