Easy "Full Hugo" simple online shop with just Netlify + Stripe

Hello,

Here is the Hugo code, ready to be used for a simple online shop with only Netlify & Stripe.

I followed a lot of thread about creating an online shop with Hugo, but nothing really was simple or ready or they were using a SAAS shop over a payment process, and the costs for that were too big for small amounts/items.

On this thread, I found recently a great tutorial about creating a small site for selling items, using only Netlify & Stripe. Thanks @alexandros !

It worked fine, but there were some problem for (me &) Hugo:

  • The shop was fully done in Javascript
  • It was not multi-language
  • Shipping countries allowed was just 2
  • The images were “static” images, not “assets” because of the way Stripe works,
  • You had to give the full real http:// url in the products.json,
  • Only one image for items,
  • No lazy loading
  • etc


But what they have done was amazing, very didactic and ready to use with only Stripe & Netlify.

So I worked a little, and here it is, based on this great tutorial : a solution for Hugo, ready to be used as is.
And this address all the concerns I listed above.

First : watch their video (30 min) so you understand how this will work, and what you have to do on Netlify (& Stripe).

Then use & adapt my code in your Hugo project.

I tried to comment a lot of code, so it will be easier to understand what part of code is doing what function. And then for you to adapt it to your needs.

Enjoy.

Screenshot

This is a really basic shop, where you can order only one item at a time. The purpose of this was not the UI but the plumbing to connect Hugo/Stripe/Netlify.

File structure

/assets/images/shop/

/functions/data/products.json
/functions/create-checkout.js
/layouts/partials/shop.html
/static/js/stripe-purchase.js

Items file : /functions/data/product.json

[
   {
      "sku" : "MAGNET-01",
      "name" : "Magnet DĂ©capsuleur",
      "description": "Magnet décapsuleur du camping Arolla",
      "image": "/images/shop/magnet-logo.jpg",
      "image_hover": "/images/shop/magnet-decapsuleur.jpg",
      "amount": "860",
      "currency": "CHF"
   },
   {
      "sku" : "BUNDLE-01",
      "name" : "Pack extra",
      "description": "Bundle Magnet + Carte Postale",
      "image": "/images/shop/bundle-magnet-carte.jpg",
      "amount": "1000",
      "currency": "CHF"
   },
   {
      "sku" : "CARTE-01",
      "name" : "Carte Postale",
      "description": "Carte Postale du camping Arolla",
      "image": "/images/shop/carte-recto.jpg",
      "image_hover": "/images/shop/carte-verso.jpg",
      "amount": "190",
      "currency": "CHF"
   }
]

partial /layouts/partials/shop.html

This layout uses the bootstrap framework. Adapt the layout to your needs or framework.

<!--Mandatory  script pour STRIPE -->
<script src="https://js.stripe.com/v3/"></script>

<!-- Titre du SHOP -->
<section class="section bg-gray" id="shop">
   <!-- Shop managed directly by Hugo -->
   <div class="container">
      <div class="row align-items-end">
         <!-- data articles MANDATORY put in /functions/data : needed for create-checkout.js used by Netlify function -->
         {{- $count := 0 }}
         {{- $dataJSON := getJSON "/functions/data/products.json" }}
         {{- range $dataJSON }}
         {{- $count = add $count 1 }}
            <div class="col-lg-4 col-md-6 col-sm-12 ">
               <div class="product">
                  <!--  Images in ASSETS
                        Manage switch 2 images by prodcut / with simple mouse over
                        and adapt same height to avoid flicking -->
                  {{- $img_original := resources.Get .image  -}}
                  {{- $img := $img_original.Resize "400x400" -}}
                  {{- $img_hover := "" }}
                  {{- if .image_hover }}
                     {{- $img_hover_original := resources.Get .image_hover  -}}
                     {{- $img_hover = $img_hover_original.Resize "400x400" -}}
                  {{- end }}
                  {{- $lazy := "" -}}
                  <img
                     {{- if site.Params.global.lazyload.enable }}
                        {{- $lazy = site.Params.global.lazyload.label -}}
                        {{- $placeholder := $img_original.Resize "3x q20" }}
                        src="data:image/jpeg;base64,{{ $placeholder.Content | base64Encode }}"
                        data-src="{{- $img.RelPermalink -}}"
                     {{- else }}
                        src="{{- $img.RelPermalink -}}"
                     {{- end }}
                     class = "img-fluid shadow rounded {{ $lazy -}}"
                     alt = "{{ .name }}"
                     title = "{{- .description -}}"
                     width = "{{- $img.Width -}}" height = "{{- $img.Height -}}"
                     {{- if .image_hover }}
                        onmouseover="this.src='{{ $img_hover.RelPermalink }}'"
                        onmouseout="this.src='{{ $img.RelPermalink }}'"
                     {{- end }}
                  >
                  <!-- Use this code if you want to use images from STATIC folder
                  <img src="{{ .image | relURL }}"
                     {{- if .image_hover }}
                        onmouseover="this.src='{{ .image_hover }}'"
                        onmouseout="this.src='{{ .image }}'"
                     {{- end }}
                     alt = "{{ .name }}"
                     />
                  -->

                  <h2>{{ .name }}</h2>
                  <p class="description">{{ .description}}</p>
                  <p class="price">{{ i18n "prix-shop" }} : {{ lang.NumFmt 2 (div (float .amount) 100) }} {{ .currency }}</p>
                  <form action="" method="POST" id="hugoform{{ $count }}">
                     <label for="quantity">{{ i18n "quantity-shop" }} :</label>
                     <input type="number" id="quantity" name="quantity" value="1" min="1" max="10" />
                     <input type="hidden" name="sku" value="{{ .sku }}" />
                     <!-- For multilanguage sites -->
                     <input type="hidden" name="formlang" value="{{ $.Page.Lang }}" />
                     <!--  We manage images on shop & on Stripe checkout
                           with only ONE image on Hugo
                           Stripe Checkout need an ABSOLUTE URL reachable from Internet
                           And we have RELATIVE path in our products.json -->
                     <!-- Needed if Images are managed from STATIC -->
                     {{- $stripeImgPath := (strings.TrimRight "/" site.BaseURL) }}
                     <input type="hidden" name="stripeImgPath" value="{{ $stripeImgPath }}" />
                     <!-- Needed if Images are managed from ASSET -->
                     <input type="hidden" name="stripeImg" value="{{ $img.Permalink }}" />
                     <br>
                     <button type="submit">{{ i18n "buy-shop" }}</button>
                  </form>
               </div>
            </div>

         {{- end }}
      </div>

   </div>

   <!-- Script for this shop going to Stripe -->
   <script type="module" >
      // Manage submit button goinf to Stripe
      import { handleFormSubmission } from '/js/stripe-purchase.js';
      {{- range seq $count }}
         // Manage eventlistener for each button/form
         document.getElementById('hugoform{{ $count}}').addEventListener('submit', handleFormSubmission);
         {{- $count = sub $count 1 }}
      {{- end }}
   </script>

</section>
{{ "<!-- End shop section -->" | safeHTML }}

File : /static/js/stripe-purchase.js

// Handle form the Submit button
export async function handleFormSubmission(event) {
   event.preventDefault();

   // Custom Goal for plausible.io
   plausible('ShopOrderClick');

   const form = new FormData(event.target);

   // Get the mandatory data from the form
   //    formlang : for multilanguage sites
   //    stripeImgPath : for local images in STATIC
   //    stripeImg : for local images in ASSETS
   const data = {
      sku: form.get('sku'),
      quantity: form.get('quantity'),
      formlang: form.get('formlang'),
      stripeImgPath: form.get(`stripeImgPath`),
      stripeImg: form.get(`stripeImg`),
   };

   // Create a Stripe Checkout
   const response = await fetch ('/.netlify/functions/create-checkout', {
      method: 'POST',
      headers: {
         'content-type': 'application/json',
      },
      body: JSON.stringify(data),
   }).then((res) => res.json());

   // Manage Checkout response & SessionID
   const stripe = Stripe(response.publishableKey);
   const { error } = await stripe.redirectToCheckout({
      sessionId: response.sessionId,
   });

   if (error) {
      console.error(error);
   }

}

File : /functions/create-checkout.js

// Stripe secret Key on Netlify ENV Variable
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
// The inventory (Only one language at the moment)
const inventory = require('./data/products.json');

exports.handler = async (event) => {
   // get the mandatory information for charging the client : sku + quantity
   //   The following URLs are set according to form language
   //   Get language (formlang) transmited from the form
   //   Get Absolute path for images on the Stripe checkout page
   const { sku, quantity, formlang, stripeImgPath, stripeImg } = JSON.parse(event.body);
   // Find the product
   const product = inventory.find((p) => p.sku === sku);
   // Sanitize quantity
   const validateQuantity = quantity > 0 && quantity < 11 ? quantity : 1;

   // Needed because I use : 
   //      defaultContentLanguageInSubdir = false
   //      defaultContentLanguage = "fr"
   function root(formlang) {
      let rootUrl;
      if (formlang == "fr") {
         rootUrl = ``;
      } else {
         rootUrl = `/`+formlang;
      }
      return rootUrl;
   }
   // Get the return URL part for the used language
   const rootUrl = root(formlang);

   // Create Stripe session
   const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      billing_address_collection: 'auto',
      shipping_address_collection: {
         // All available countries Worldwide
         allowed_countries: ['AC','AD','AE','AF','AG','AI','AL','AM','AO','AQ','AR','AT','AU','AW','AX','AZ','BA','BB','BD','BE','BF','BG','BH','BI','BJ','BL','BM','BN','BO','BQ','BR','BS','BT','BV','BW','BY','BZ','CA','CD','CF','CG','CH','CI','CK','CL','CM','CN','CO','CR','CV','CW','CY','CZ','DE','DJ','DK','DM','DO','DZ','EC','EE','EG','EH','ER','ES','ET','FI','FJ','FK','FO','FR','GA','GB','GD','GE','GF','GG','GH','GI','GL','GM','GN','GP','GQ','GR','GS','GT','GU','GW','GY','HK','HN','HR','HT','HU','ID','IE','IL','IM','IN','IO','IQ','IS','IT','JE','JM','JO','JP','KE','KG','KH','KI','KM','KN','KR','KW','KY','KZ','LA','LB','LC','LI','LK','LR','LS','LT','LU','LV','LY','MA','MC','MD','ME','MF','MG','MK','ML','MM','MN','MO','MQ','MR','MS','MT','MU','MV','MW','MX','MY','MZ','NA','NC','NE','NG','NI','NL','NO','NP','NR','NU','NZ','OM','PA','PE','PF','PG','PH','PK','PL','PM','PN','PR','PS','PT','PY','QA','RE','RO','RS','RU','RW','SA','SB','SC','SE','SG','SH','SI','SJ','SK','SL','SM','SN','SO','SR','SS','ST','SV','SX','SZ','TA','TC','TD','TF','TG','TH','TJ','TK','TL','TM','TN','TO','TR','TT','TV','TW','TZ','UA','UG','US','UY','UZ','VA','VC','VE','VG','VN','VU','WF','WS','XK','YE','YT','ZA','ZM','ZW','ZZ']
      },
      // The real next URL for the web site language
      // success_url: `${process.env.URL}`+rootUrl+`/thanks/`,
      // cancel_url:  `${process.env.URL}`+rootUrl+`/oops/`,
      success_url: stripeImgPath+rootUrl+`/thanks/`,
      cancel_url:  stripeImgPath+rootUrl+`/oops/`,

      // Informations about the product
      line_items: [
         {
            name: `[`+product.sku+'] '+product.name ,
            description: product.description,
            // We need ABSOLUTE path Absolute URL for images used by Stripe
            //    Use this for Images in STATIC
            // images: [stripeImgPath+product.image],
            //    Use this for Images in ASSET
            // images: [stripeImg],
            images: [stripeImg],
            amount: product.amount,
            currency: product.currency,
            quantity: validateQuantity,
         },
      ],
   });

   return {
      statusCode: 200,
      body: JSON.stringify({
         sessionId: session.id,
         publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
      }),
   };

};

Pages thanks.fr.md & oops.fr.md

You need to create 2 pages and their related languages.

  • thanks.md
  • oops.md

They are used by netlify if it succeed or if it fails or you cancel the order.

And do not forget to add this on the frontmatter so they will not be listed on your sitemap.xml

_build   :
   list  : false
   render: true

Example for oops.en.md

---
title: "Your order has not been taken into account"
url: oops
date: 2020-08-27T15:53:27+06:00
draft: false
description : "Your order has not been taken into account"
plausible_custom_goal : "ShopOrderFail"
_build   :
   list  : false
   render: true
---

## Your order has not been taken into account

We're sorry, but **the order was canceled** and your order could not complete successfully.

You have not been charged and **your order has not been taken into account**.

Example for thanks.en.md

---
title: "Thank you for your purchase"
url: thanks
date: 2020-08-27T15:53:27+06:00
draft: false
description : "Thank you for your purchase"
plausible_custom_goal : "ShopOrderSuccess"
_build   :
   list  : false
   render: true
---

## Your order has been registered

We will process your order as soon as possible.

Thank you and see you soon perhaps, on the Arolla campsite !!

The partial oops_thanks.html for those 2 pages.

<!-- Généralités de la suite de la commande/erreu stripe -->
<section class="about-2 section bg-gray" id="oops">
   <div class="container">
      <div class="row">
         <div class="col-12">
            <div class="text-left">
               {{ .Content }}
            </div>
         </div>
      </div>
      <div class="row">
         <div class="col-12">
            <div class="text-left">

               <a class="btn btn-main" href='{{ "shop/" | relLangURL }}'>
                  <i class="tf-ion-ios-cart"></i> {{ i18n "back2shop" }} <i class="tf-ion-ios-cart"></i>
               </a>

            </div>
         </div>
      </div>
   </div>
</section>

style CSS snipplet

/* Pour le shop */
.product input {
  border: 1px solid teal;
  border-radius: 0.25rem;
  font-size: 1.125rem;
  line-height: 1.25rem;
  padding: 0.2rem;
}
.product button {
  background: teal;
  border: none;
  border-radius: 0.25rem;
  color: white;
  font-size: 1.1rem;
  font-weight: 800;
  line-height: 1.1rem;
  padding: 0.25rem;
  width: 110px;
  height: 30px;
  margin-bottom: 40px;
}
.product h2 {
  font-size: 1.25rem;
}
.product .price {
  font-weight: 800;
}
.product .description {
  font-style: italic;
}

18 Likes

I’m working on a more sophisticated UI, where you can order any number of items, and separate postage : this change the form & .js used & transmitted to the checkout.
Automatic postage according to item’s weight, and a nicer UI on mobile with the shopping card sticky at the bottom when you browse items, and with automatic update for all prices, and weight/postage prices.

When it’s finished and production ready, and if there is some interest, i can share it too.

8 Likes

@divinerites

Thank you so much for sharing this tutorial! :four_leaf_clover:

Also looking forward to the more sophisticated UI version!

3 Likes

As I’m really javascript illiterate, I’m struggling to learn the basics with creating/testing arrays & dict. But will finally win :slight_smile:

1 Like

Just looking around for Ecommerce projects with Hugo and found this thread. I’ve created my own static Ecommerce site that assume you have a Stripe setup to take payment:

Feedback would be great. It leverages https://gohugo.io/templates/data-templates/ and https://gohugo.io/hugo-pipes/js ESbuild.

4 Likes

@hendry

Very cool. Thanks for sharing.

P.S. LMAO @ the unboxing beer video. :rofl:

1 Like

Thanks for sharing this code, It will be great if you share a repository with all of this code. and can I use stripe without netlify?

1 Like

@hendry,
Thank you for sharing your code and site. I would like to give continue build on it, can you explain a bit more on how the code structure. I am new to Hugo, and would like to properly integrate Hugo with Stripe base off of your solution.

Thanks so much!
Lucky

Hello @somratpro,

At the moment this project is on hold, so no repository to share.

And since this use lamba Functions, Netlify (or at least a hosting solution who does that) is mandatory.

Please email me any specific questions!

Thanks for your reply! email sent to your email address listed on github. appreciate all the help!