In this post, you will learn how to build a full stack serverless JAMstack ECommerce store using Gatsby, AWS Amplify and JAMstack ECommerce.
While this post focuses on the specific use case of building an ECommerce application, the services and features I will showcase are the building blocks for most all real-world production applications, so I hope you will find it useful.
Laying the groundwork
For this site, I've chosen to use Gatsby in order to get the benefits of a static site, including better performance, better SEO, and cheaper / easier scalability. Next.js (React) and Nuxt (Vue) are other options that would do the job just as well, but I've gone with Gatsby because of my previous experience with it as well as the robust developer community and documentation available at the time of this writing.
The app we will be building has the following features:
- Ability to query inventory from an API
- At build time, create navigation based on inventory categories
- At build time, create pages for each inventory item and category pages for each nav item along with corresponding views
- Shopping cart / checkout
- Admin panel for creating / updating inventory
- Downloading of images at build time to serve from the public folder vs dynamic fetching
Based on these features, we can assume that the app will have the following requirements from an API / service standpoint:
- Authentication (sign up, sign in)
- Dynamic group authorization (only Admin users can view and update inventory)
- API with create, update, and delete operations
- Public API access for querying the API
- Private API access so that only Admin users can create / update / delete inventory
- Image / asset hosting
To build out these features on both the front and the back end we will be using the Amplify Framework:
- Amplify CLI for creating and configuring AWS services
- Amplify Client libraries for interacting with the services
- Amplify Console to host and view the app and features after they are deployed.
Let’s start building!
To follow along with this tutorial, you need to have an AWS account (sign up here)
Getting started
To get started, clone the Gatsby JAMstack ECommerce starter project that will serve as the base of the application we'll be building:
$ git clone https://github.com/jamstack-cms/jamstack-ecommerce.git
Next, change into the directory and install the dependencies using npm or yarn:
$ cd jamstack-ecommerce
$ npm install
# or
$ yarn
Next, start the project to get an idea of how the app will look:
$ gatsby develop
When the app loads, you should be able to go to http://localhost:8000/ and see something like this:
Great, we're now up and running!
You may be wondering where the inventory is coming from. Starting off, the inventory is hard-coded in the inventory file located at providers/inventory.js.
This is not ideal though because keeping up with everything locally is hard to scale. Instead, we propose to make the inventory dynamic and be able to add and update inventory via and admin panel using some type of content management system.
To do so, we'll need to set up an API. To start, create the Amplify project so we can begin migrating the inventory provider to a real back end provider.
Installing Amplify and initializing an Amplify project
Before you can use Amplify, you'll first need to have or create an AWS Account.
Next, install the Amplify CLI globally from the command line:
$ npm install -g @aws-amplify/cli
If the CLI is installed, you should be able to run the amplify command and see some output and help options.
$ amplify
Now that the CLI is successfully installed, we now need to configure the CLI. To do so, run the configure command:
$ amplify configure
This will walk you through the steps to create and configure AWS user credentials locally. For a guided walkthrough of these configuration steps, check out this video.
Creating the Amplify project
After the CLI has been configured you can create a new Amplify project:
$ amplify init
? Enter a name for the project: jamstack-ecommerce
? Enter a name for the environment dev
? Choose your default editor: <your_preferred_editor>
? Choose the type of app that youre building: javascript
? What javascript framework are you using: react
? Source Directory Path: src
? Distribution Directory Path: public
? Build Command: gatsby build
? Start Command: npm run start
- When prompted for an AWS profile, choose the profile you created in the configuration step.
After the initialization has been completed, you should now see 2 artifacts created for you in your project directory:
- src/aws-exports.js - This file will hold the key value pairs of the resource information for the services created by the CLI.
- amplify directory - This will hold the back end code we write for things like GraphQL schemas and serverless functions managed by the AWS services we'll be using.
Now that we have the base project set up, let's also go ahead and install the AWS Amplify client library:
$ npm install aws-amplify
# or
$ yarn add aws-amplify
Creating the back end services
Now we are ready to go and can start creating the services we'll be integrating into the app. Let's first start with authentication.
Authentication
The authentication setup for this app will need to accomplish the following things:
- Enable users to sign up and sign in
- Detect Admin users based on a predetermined list of admins and place them in the Admin group once they sign up.
We can do this with a combination of Amazon Cognito (managed authentication service) and AWS Lambda (functions as a service).
We'll create an authentication service that will call (trigger) a Lambda function when someone signs up (post-confirmation). In that function we can determine whether or not they will be allowed Admin access based on their email address.
To create the service, we'll use the Amplify add command:
$ amplify add auth
? Do you want to use the default authentication and security configuration? Default configuration
? How do you want users to be able to sign in? Username
? Do you want to configure advanced settings? Yes
? What attributes are required for signing up? Email (keep defaults)
? Do you want to enable any of the following capabilities? Add User to Group
? Enter the name of the group to which users will be added. Admin
? Do you want to edit your add-to-group function now? Y
Now, let's edit the code for the post-confirmation Lambda trigger. In amplify/backend/function/function_name/src/add-to-group.js, use the following code:
// amplify/backend/function/function_name/src/add-to-group.js
const aws = require('aws-sdk');
exports.handler = async (event, context, callback) => {
const cognitoidentityserviceprovider = new aws.CognitoIdentityServiceProvider({ apiVersion: '2016-04-18' });
// Here, update the array to include the Admin emails you would like to use
let adminEmails = ["dabit3@gmail.com"], isAdmin = false
if (adminEmails.indexOf(event.request.userAttributes.email) !== -1) {
isAdmin = true
}
if (isAdmin) {
const groupParams = {
GroupName: process.env.GROUP, UserPoolId: event.userPoolId,
};
const addUserParams = {
...groupParams, Username: event.userName,
};
try {
await cognitoidentityserviceprovider.getGroup(groupParams).promise();
} catch (e) {
await cognitoidentityserviceprovider.createGroup(groupParams).promise();
}
try {
await cognitoidentityserviceprovider.adminAddUserToGroup(addUserParams).promise();
callback(null, event);
} catch (e) {
callback(e);
}
} else {
callback(null, event);
}
};
Update the adminEmails
array to include the emails you'd like to allow Admin access.
This function will add a user to the Admin group if their email is included in the adminEmails
array.
Storage
Next, let's create the image storage service using Amazon S3:
$ amplify add storage
? Please select from one of the below mentioned services: Content
? Please provide a friendly name for your resource...: <resource_name>
? Please provide bucket name: <some_unique_bucket_name>
? Who should have access: Auth and guest users
? What kind of access do you want for Authenticated users? create, update, read, delete
? What kind of access do you want for Guest users? read
? Do you want to add a Lambda Trigger for your S3 Bucket? N
API & database
The last thing we need to create is an API and a database to store our data. This API needs to allow both authenticated and unauthenticated access.
Authenticated Admin users should be able to create and update items in the database while unauthenticated access will allow us to query the API at build time to fetch the data needed for the application.
To allow this, we'll create an AWS AppSync GraphQL API & Amazon DynamoDB NoSQL database using the CLI:
$ amplify add api
? Please select from one of the below mentioned services: GraphQL
? Provide API name: furnitureapi
? Choose the default authorization type for the API: Amazon Cognito User Pool
? Do you want to configure advanced settings for the GraphQL API: Yes
? Configure additional auth types? Y
? Choose the additional authorization types you want to configure for the API: API Key
? Enter a description for the API key: gatsby
? After how many days from now the API key should expire: 100
? Configure conflict detection? N
? Do you have an annotated GraphQL schema? N
? Do you want a guided schema creation? Y
? What best describes your project: Single object with fields
? Do you want to edit the schema now? Y
This should open the GraphQL schema located at amplify/backend/api/postershop/schema.graphql. Here, update the schema to be the following:
type Product @model
@auth(rules: [
{ allow: public, operations: [read] },
{ allow: groups, groups: ["Admin"] }
]) {
id: ID!
categories: [String]!
price: Float!
name: String!
image: String!
description: String!
currentInventory: Int!
brand: String
}
This GraphQL schema has a few additional directives that you might not see on a traditional schema:
@model - This directive will scaffold out a DynamoDB database, addition CRUD (Create, Read, Update, Delete) & List GraphQL schema operations, and GraphQL resolvers mapping between the operations and the database.
@auth - This directive allows us to set up authorization rules on either a GraphQL type or field.
These directives are part of the GraphQL Transform library of Amplify. To learn more about this library and these directives, check out the documentation here.
In the schema we've created, we want to have two authorization types:
- Admin users can perform all operations
- Public access to read items
The services should now be configured and can be deployed to AWS. To do so, we can run the push command:
$ amplify push --y
All of the services have now been deployed and we can start integrating them into the the client application!
To view the AWS services that have been created at any time, open the Amplify console with the following command:
$ amplify console
Client integration
Now that the back end services are deployed, the next thing we need to do is configure the Gatsby project to recognize the Amplify project. To do so, open gatsby-browser.js and add the following code:
import Amplify from 'aws-amplify'
import config from './src/aws-exports'
Amplify.configure(config)
Client authentication
Once the client app is configured, implement authentication for the admin panel. To do so, open src/pages/admin.js and import the Auth
class from Amplify:
// src/pages/admin.js
import { Auth } from 'aws-amplify'
Next, modify the signUp
, confirmSignUp
, signIn
, and signOut
methods to the following:
signUp = async (form) => {
const { username, email, password } = form
// step 1: Sign up a new user
await Auth.signUp({
username, password, attributes: { email }
})
this.setState({ formState: 'confirmSignUp' })
}
confirmSignUp = async (form) => {
const { username, authcode } = form
// step 2: Use MFA to confirm the new user
await Auth.confirmSignUp(username, authcode)
this.setState({ formState: 'signIn' })
}
signIn = async (form) => {
const { username, password } = form
// step 3: Sign in the new user
await Auth.signIn(username, password)
// step 4: Check to see if the user is an Admin, if so, show the inventory view.
const user = await Auth.currentAuthenticatedUser()
const { signInUserSession: { idToken: { payload }}} = user
if (payload["cognito:groups"] && payload["cognito:groups"].includes("Admin")) {
this.setState({ formState: 'signedIn', isAdmin: true })
}
}
signOut = async() => {
// allow users to sign out
await Auth.signOut()
this.setState({ formState: 'signUp' })
}
Authentication is now enabled and users can begin signing up and signing in to view the inventory.
In the bottom right navigation, click on Admins to view the admin panel to sign up and sign in
In this component, we use a few different methods on the Auth class like signUp
and signIn
. Auth has over 30 different methods for handling user authentication. To learn more, check out the documentation here or the API here.
Next, let's test it out:
$ gatsby develop
You'll notice that when you sign in and refresh the page, the user state is not persisted. We can fix this by checking to see if the user is signed in when the app loads. To do so, update componentDidMount
with the following code:
async componentDidMount() {
const user = await Auth.currentAuthenticatedUser()
const { signInUserSession: { idToken: { payload }}} = user
if (payload["cognito:groups"] && payload["cognito:groups"].includes("Admin")) {
this.setState({ formState: 'signedIn', isAdmin: true })
}
}
Client API integration
Now that we have authentication working, let's use the API to create and update data in our app. To do so, we'll be first working with the inventory provider located at src/templates/ViewInventory.js. Here, let's update it to fetch data from our real API.
First, import GraphQL query and the APIs needed from AWS Amplify:
// src/templates/ViewInventory.js
import { API, graphqlOperation } from 'aws-amplify'
import { listProducts } from '../graphql/queries'
Next, update the fetchInventory
method to fetch the data from the API:
fetchInventory = async() => {
const inventoryData = await API.graphql(graphqlOperation(listProducts))
const { items } = inventoryData.data.listProducts
console.log("inventory items: ", items)
this.setState({ inventory: items })
}
You'll notice that when we run the app and console.log the items coming back from the API, there is an empty array. This is because we have yet to create any real items in our database.
To add the ability to create items, we'll need to make some updates to src/components/formComponents/AddInventory.js.
First, update the imports to add the following:
// src/components/formComponents/AddInventory.js
import { Storage, API, graphqlOperation } from 'aws-amplify'
import { createProduct } from '../../graphql/mutations'
import uuid from 'uuid/v4'
Next, update the onImageChange
and addItem
methods to the following:
onImageChange = async (e) => {
const file = e.target.files[0];
const fileName = uuid() + file.name
// save the image in S3 when it's uploaded
await Storage.put(fileName, file)
this.setState({ image: fileName })
}
addItem = async () => {
const { name, brand, price, categories, image, description, currentInventory } = this.state
if (!name || !brand || !price || !categories.length || !description || !currentInventory || !image) return
// create the item in the database
const item = { ...this.state, categories: categories.replace(/\s/g, "").split(',') }
await API.graphql(graphqlOperation(createProduct, { input: item }))
this.clearForm()
}
Now, you'll notice that you can create items and when we view the inventory, they show up!
Client Storage integration
One odd thing you'll notice is that the images do not show up in the inventory view. This is because we are attempting to render an image key from S3 that is not yet signed. We can fix this by opening the image component at src/components/image.js and adding image signing from S3.
We will check to see if the image is a locally downloaded image (if the image path includes downloads). If it does not, then we know it is a remote image from S3 and we will fetch the signed URL for the image.
First, import the Storage
class from Amplify:
// src/components/image.js
import { Storage } from 'aws-amplify'
Next, update the fetchImage
function to this:
async function fetchImage(src, updateSrc) {
if (!src.includes('downloads')) {
const image = await Storage.get(src)
updateSrc(image)
} else { updateSrc(src) }
}
Now, we should see the images rendered in the list.
We next need to enable the editing and deleting of items. You'll notice that if you edit an item in the Admin view and refresh, the changes do not persist. To fix that, open src/templates/ViewInventory.js and make the following changes.
First, import the updateProduct
and deleteProduct
mutations:
// src/templates/ViewInventory.js
import { updateProduct, deleteProduct } from '../graphql/mutations'
Next, update the saveItem
and deleteItem
methods to the following:
saveItem = async index => {
const inventory = [...this.state.inventory]
inventory[index] = this.state.currentItem
await API.graphql(graphqlOperation(updateProduct, { input: this.state.currentItem }))
this.setState({ editingIndex: null, inventory })
}
deleteItem = async index => {
const id = this.state.inventory[index].id
const inventory = [...this.state.inventory.slice(0, index), ...this.state.inventory.slice(index + 1)]
this.setState({ inventory })
await API.graphql(graphqlOperation(deleteProduct, { input: { id }}))
}
Now when we save an item, the updates also go to the database!
Build-time API integration
Finally, we need to change the build step to use the new API we've created instead of the hard-coded inventory data we've created. When we run gatsby develop or gatsby build, we will use the public API access to enable the system to query the data from the API and use it for the app.
We also want to include in the build step a way to download the images locally in our project so we are not fetching remote images, instead we are rendering a local copy of the image that we will be downloading and storing in a local downloads directory in the public folder.
For this to work, first create at least 4 items in your inventory from the admin panel.
Next, create downloadImage.js in the utils folder. This function will allow us to download images locally using the file system (fs
) module:
// utils/downloadImage.js
import fs from 'fs'
import axios from 'axios'
import path from 'path'
function getImageKey(url) {
const split = url.split('/')
const key = split[split.length - 1]
const keyItems = key.split('?')
const imageKey = keyItems[0]
return imageKey
}
function getPathName(url, pathName = 'downloads') {
let reqPath = path.join(__dirname, '..')
let key = getImageKey(url)
key = key.replace(/%/g, "")
const rawPath = `${reqPath}/public/${pathName}/${key}`
return rawPath
}
async function downloadImage (url) {
return new Promise(async (resolve, reject) => {
const path = getPathName(url)
const writer = fs.createWriteStream(path)
const response = await axios({
url,
method: 'GET',
responseType: 'stream'
})
response.data.pipe(writer)
writer.on('finish', resolve)
writer.on('error', reject)
})
}
export default downloadImage
Now open gatsby-node.esm.js. Add the following imports and statements at the top of the file:
// gatsby-node.esm.js
import config from './src/aws-exports'
import axios from 'axios'
import tag from 'graphql-tag'
import fs from 'fs'
import downloadImage from './utils/downloadImage'
import Amplify, { Storage } from 'aws-amplify'
Amplify.configure(config)
const graphql = require('graphql')
const { print } = graphql
Next, create a new function called fetchInventory
to fetch inventory from our new API and place the function anywhere in gatsby-node.esm.js.
This function will also map over all of the inventory items and download the images locally at build time using the downloadImage
function that we created in the previous step:
async function fetchInventory() {
/* new */
const listProductsQuery = tag(`
query listProducts {
listProducts(limit: 500) {
items {
id
categories
price
name
image
description
currentInventory
brand
}
}
}
`)
const gqlData = await axios({
url: config.aws_appsync_graphqlEndpoint,
method: 'post',
headers: {
'x-api-key': config.aws_appsync_apiKey
},
data: {
query: print(listProductsQuery)
}
})
let inventory = gqlData.data.data.listProducts.items
if (!fs.existsSync(`${__dirname}/public/downloads`)){
fs.mkdirSync(`${__dirname}/public/downloads`);
}
await Promise.all(
inventory.map(async (item, index) => {
try {
const relativeUrl = `../downloads/${item.image}`
if (!fs.existsSync(`${__dirname}/public/downloads/${item.image}`)) {
const image = await Storage.get(item.image)
await downloadImage(image)
}
inventory[index].image = relativeUrl
} catch (err) {
console.log('error downloading image: ', err)
}
})
)
return inventory
}
Finally, in exports.sourceNodes
and exports.createPages
update the calls to getInventory
with the new fetchInventory
functions:
/* replace const inventory = await getInventory() with this 👇 */
const inventory = await fetchInventory()
Run the develop
command to test it out:
$ gatsby develop
Running a new build will fetch the data from the GraphQL API and create a new navigation based on the updated product categories and also build out a new static version of the site.
Conclusion
At this point, you are up and running with an MVP of a real-world and scalable ECommerce application running on AWS!
From here, you may want to dive deeper on the Amplify documentation to learn more about the APIs and services we’ve used as well as the other APIs that we’ve not yet worked with.
So far we've set up the following features:
- Authentication (Amazon Cognito)
- API (AWS AppSync)
- Storage (Amazon S3)
You might also be interested in learning about:
- Predictions (ML / AI)
- Interactions (chat bots, Amazon Lex)
- Analytics (Amazon Pinpoint)
- REST APIs (AWS Lambda + Amazon API Gateway)
Next steps
Here are a few things you can do to continue improving this app.
Hosting - deploy to the Amplify Console
If you host your app in GitHub, BitBucket, GitLab, or AWS CodeCommit, you can easily deploy the entire site to live hosting and add a custom domain in just a few minutes using the Amplify Console. To see a quick video of how to do this with a Gatsby site in less than one minute, check out this video.
Configure server-side logic to process the payments with Stripe. You can do this easily from where we currently are by adding a serverless function and API using Amplify and the API category:
$ amplify add function
$ amplify add api
- Choose REST
If you’d like to see an example of the function code needed to interact with stripe, check out this code snippet.
Also, consider verifying totals by passing in an array of IDs into the function, calculating the total on the server, then comparing the totals to check and make sure they match.
Update inventory items as they are purchased
To keep the inventory up to date, you probably want to decrement the inventory as a purchase is made. To do this, you could send an update request to decrement the database before an order was confirmed.
To make this even more secure, you could use a DynamoDB Transaction to only process the order if there was enough in the inventory and decrement the number of items if there are any in the inventory in a single operation.
To learn more about Amplify, check out these resources:
Documentation
Awesome AWS Amplify
My YouTube
Follow me on Twitter at dabit3
Top comments (17)
well explained..!! but why need to go through jamstack rather then magento, shopify or wordpress? because they already provide great few click installation & configuration. and it's very secure and popular framework.
Good question, you can definitely use Shopify as the inventory provider via their API in the step where we create the Amplify inventory provider, the difference there is that you would not be able to have authorization rules on the client where users could sign up as admins and add / update / delete items.
Also, Magento, Wordpress , and shopify when using their site generators are not nearly as fast as JAMstack apps. Check out the live demo here to get an idea of the difference in speed and performance.
Yeah, speed is awesome.!!
Another nice overview, thanks Nader.
S3 storage is giving me 403 when I try to build, I just gave the bucket the shotgun approach with the following
Hi Nader,
I am not sure if I am doing something wrong but cannot get past first base.
If I follow your step 1
git clone github.com/jamstack-cms/jamstack-e...
I do indeed get jamstack-ecommerce package
But looking in files and the package.json - pasted below
It appears to be a React rather than a Gatsby project?
Have I gone wrong from the start here or the Git moved somehow?
Thanks in advance Mark
{
"name": "jamstack-ecommerce",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@stripe/react-stripe-js": "^1.1.2",
"@stripe/stripe-js": "^1.11.0",
"autoprefixer": "^10.1.0",
"next": "10.0.4",
"postcss": "^8.2.2",
"react": "17.0.1",
"react-dom": "17.0.1",
"react-icons": "^4.1.0",
"react-toastify": "^6.2.0",
"tailwindcss": "^2.0.2",
"uuid": "^8.3.2"
}
}
I have some problems to login, first I sign up correctly, even I can see the confirmation in AWS Cognito. But when I try to login, nothing happens, not even console show me anything.
Someone has the same problem?
Hi Nader
Great tutorial - thanks!
My one question is what happens once the order is created
I see in src>pages>checkout.js line 114 that there's a comment to call API.
How would you go about putting all orders in the database in a new orders table and tying it to the user who created the order so that they can see their history for example.
Thanks again for the great tut
Very good article, Nader. well descriptive. Thanks.
Please, I have a couple of issues:
Unhandled Rejection (TypeError): aws_amplify_WEBPACK_IMPORTED_MODULE_9_.API.grapghql is not a function
_callee2$
localhost:8000/commons.js:281286:70
281283 | categories: categories.replace(/\s/g, "").split(',')
281284 | });
281285 | _context2.next = 6;
the source code error:
Unhandled Rejection (TypeError): aws_amplify_WEBPACK_IMPORTED_MODULE_9_.API.grapghql is not a function
_callee2$
src/components/formComponents/AddInventory.js:29
26 | // })
27 | // this.setState({ image: storageUrl })
28 | }
Sorry I asked 3 questions in one, issue is, I am hurrying up to round up this task so I move on to integration of financial-payment solutions (stripe and others).
Thanks
This is awesome! Thanks Nader!
Thank you. Based on the information you provided, I am currently creating an ecommerce site using aws, gatsby, etc. I'll show you when it's done!
this is amazing, awesome job, Nader!
Thanks!!
I'm going to do some contributions to the project on github, is there any way i can contact you?
Is it a good practice to use @model, @searchable - basically amplify thing for production apps, isn't it more suited for a POC?