Building a Svelte ECommerce Site
Fauna and Svelte are two great pieces of web technology that can work really well together. Here's an article I contributed to the Fauna blog on how to get up and running using them for an ecommerce site.
Today, I’ll be looking at how we can combine two newer, fast-growing web technologies to get a web application rapidly built and set up to scale infinitely.
Those two technologies are Svelte and Fauna. Svelte is one of the newest technologies in the world of frontend development, building on what React, Angular, Vue and others before have done but also simplifying things quite a bit. If you’re new to Svelte, I recommend starting with their tutorial here. Fauna is a modern, document-based, serverless database that runs in the cloud and offers a generous free tier to get you started. Here’s a link to Fauna’s documentation if you need to get acquainted with it before starting into this tutorial.
This tutorial will cover all the basics you need to create a small eCommerce site using Fauna and Svelte. You’ll learn:
- How to create a Fauna database and populate it with collections, indexes, and documents
- The basic principles of Fauna database design and the Fauna Query Language (FQL)
- How to connect a Svelte application to Fauna and display data
- How to implement a basic shopping cart for a Svelte eCommerce application
- How to write data back to Fauna from our Svelte application
Getting Started
Go to fauna.com and register for a new account. In the dashboard, click Create Database. Our eCommerce site is going to be for a sample bakery, so I’ve named my database SvelteBakery and put it under the US region group:
Once the database is up and running, we’ll create a couple of collections — Fauna’s equivalent of a table in a traditional relational database. I’ve selected the default of 30 days of retained history for each document (equivalent to a row) in the collection, and no TTL specified. Specifying a TTL would remove records that have been untouched for a certain number of days. I’m creating one collection for product categories, one for the products themselves, and one to hold order data.
Next, I’m going to insert some data into my database. I’ll start with the Categories collection, click in there and then click New Document. If you’re familiar with JSON, this will be pretty straightforward, you can put any JSON you want inside the brackets. For example, to add a category called Dessert, you can do the following:
You can also use the shell (either inside the Fauna dashboard or via the fauna-shell NPM package), or any language that supports the Fauna API, which I’ve used in this case to add items in bulk:
Create(Collection("Categories"), { data: { name: "Sandwiches" } })
Create(Collection("Categories"), { data: { name: "Sides" } })
Create(Collection("Categories"), { data: { name: "Beverages" } })
Create(Collection("Categories"), { data: { name: "Desserts" } })
We’ll get back the inserted data, with a unique reference for each that we’ll need to use later to add items:
[
{
ref: Ref(Collection("Categories"), "322794748885598273"),
ts: 1644099911490000,
data: {
name: "Sandwiches"
}
},
{
ref: Ref(Collection("Categories"), "322794748889792577"),
ts: 1644099911492000,
data: {
name: "Sides"
}
},
{
ref: Ref(Collection("Categories"), "322794748889793601"),
ts: 1644099911495000,
data: {
name: "Beverages"
}
},
{
ref: Ref(Collection("Categories"), "322794748889794625"),
ts: 1644099911497000,
data: {
name: "Desserts"
}
}
]
Next, let’s create a product. Go to the Products collection, and let’s start with a BLT sandwich:
The name and price look pretty straightforward, but what’s going on with that category? This is what’s known as a reference in Fauna. Essentially, we’re telling the database to refer to the Categories collection and bring back the document with an ID of 322794748885598273.
Using the ID as a reference rather than just adding the category name makes it easy to change category details later on. For example, if I want to change a category name from Beverages to Drinks, I can do so in the Categories collection without having to touch the Products collection at all.
Next, I’m going to go ahead and bulk add a bunch of products to the database. I’ll do this through the Fauna shell, using Fauna’s Map and Lambda functions:
Map(
[
['BLT', 6.99, '322794748885598273'],
['Turkey and Swiss', 7.99, '322794748885598273'],
['Egg, Bacon, and Cheese', 4.99, '322794748885598273'],
['Chicken Bacon Ranch', 7.99, '322794748885598273'],
['Grilled Cheese', 5.99, '322794748885598273'],
['Potato Chips', 1.25, '322794748889792577'],
['Mac and Cheese', 3.99, '322794748889792577'],
['Potato Salad', 2.99, '322794748889792577'],
['Soup of the Day', 3.99, '322794748889792577'],
['Bottled Water', 1.50, '322794748889793601'],
['Fountain Drink', 1.99, '322794748889793601'],
['Coffee', 1.99, '322794748889793601'],
['Cold Brew', 2.99, '322794748889793601'],
['Espresso', 3.99, '322794748889793601'],
['Blueberry Muffin', 1.99, '322794748889794625'],
['Chocolate Chip Muffin', 1.99, '322794748889794625'],
['Croissant', 1.49, '322794748889794625'],
['Brownie', 1.49, '322794748889794625'],
['Chocolate Chip Cookie', .99, '322794748889794625'],
['Slice of Cheesecake', 2.99, '322794748889794625']
],
Lambda(
['name', 'price', 'category'],
Create(
Collection('Products'),
{
data: {
name: Var('name'),
price: Var('price'),
category: Ref(Collection('Categories'), Var('category'))
}
}
)
)
)
If you’re familiar with the map function in JavaScript, this may look familiar to you. Essentially, I’ve built an array of arrays, and the map function loops through the outer array and passes each inner array to a Lambda function for processing. The Lambda destructures the inner array into its components: the name, price, and category ID. Then I take that data and pass it into a Create function to add a new document to the Products collection. This allowed me to add all of these rows in less than half a second, and it saved me some extra typing.
Next, I’m going to create a few indexes to help with data retrieval. First, we’ll need one for all_categories, as this is how we’ll group our menu in the frontend. Go ahead and run the following in the Fauna console:
CreateIndex({
name: "all_categories",
source: Collection("Categories"),
values: [
{ field: ["ref"] },
{ field: ["data", "name"] }
]
})
This will create an index called all_categories that will return each category’s ID and name. I’ve listed the ref (ID) first because we’ll use that to sort.
Next, I’m going to create the main index we’ll actually be using in the application. I’m planning on having the menu of products, grouped by category. So, we’ll want an index where the field to search is the category, and the values returned are the product names and prices. Here’s an example of how to create an Index in the dashboard GUI, rather than the console:
Now, when I go back to that index, I can use the FQL option to search it. The code snippet gets cut off in the screenshot, but it’s just Ref(Collection(‘Categories’), ‘322794748885598273’)
.
That will bring back the name and price for each item in category 322794748885598273, which is Sandwiches.
Security
There’s one other thing we need to do on the database side before we move over to building our application. We’ll need to create a role in Fauna that we can use for fetching data. On the security tab in the dashboard, select Roles, and click New Custom Role. I used the title Svelte
here, but you can pick whatever you’d like. Then, add all three of our Collections and our Indexes using the dropdowns. Give the Products and Categories the Read permission by checking the box in that column, and give Orders the Create permission. Then, give both Indexes the Read permission.
Here we’re telling Fauna that we want read access to our products and categories to populate the menu on the frontend. We also want to be able to write new orders to the orders collection when an order is submitted. Finally, we want read-only access to our indexes. We don’t want to give this role access to do anything nefarious, like create new products in our Products collection, or to delete Categories, so it’s important to be very choosy here.
Once that’s done, go back to the Security tab, select Keys, and create a new key using the Svelte role:
You’ll get the key back when you submit it; keep it somewhere safe because you will only see it this one time.
Building the Frontend
We now have enough set up on the database to begin working on our Svelte application. In Visual Studio Code, I’ve opened a folder I created called SvelteBakery, and then I ran the following terminal commands to spin up my project:
npx degit sveltejs/template .
npm install
Inside of the src folder, I’ve also created a components sub-folder and a stores sub-folder. The folder structure should look like this now:
Let’s begin with the stores folder. Here, we’re going to create a file called stores.js
and populate it with the following code:
import { writable } from ‘svelte/store’
export const shoppingCart = writable([])
Svelte makes it very easy to share data across your components with these built-in stores. If you’re coming from another framework, such as React, you may be used to using something like Redux for state management, but Svelte includes this out of the box. We’ll use that store later in the tutorial to track items in our cart.
I’m also creating a store to make it easy to share our Fauna client across components. To do this, I’ve created a file called fauna.js
inside the same stores folder and populated it with this similar looking code:
import { writable } from 'svelte/store'
export const fauna = writable({})
The only real difference is that the shoppingCart was initialized with an empty array, while the fauna store was initialized with an empty object.
I’m going to use that in our main App.svelte file, which now looks like the following:
<script>
import ProductMenu from "./components/ProductMenu.svelte";
import ShoppingCart from "./components/ShoppingCart.svelte";
import Nav from "./components/Nav.svelte";
import { fauna } from './stores/fauna';
let currentPage = "menu";
function connectToFauna() {
connectToFauna = () => {};
fauna.set(
{
client: new window.faunadb.Client({ domain: 'db.us.fauna.com', scheme: 'https', secret: "INSERT_SECRET_HERE" }),
q: window.faunadb.query
}
)
}
function handlePageChange(e) {
currentPage = e.detail.newPage;
}
</script>
<main>
<Nav on:changepage={handlePageChange} />
{#if currentPage === "menu"}
<ProductMenu />
{:else if currentPage === "cart"}
<ShoppingCart />
{/if}
</main>
<svelte:head>
<script src="//cdn.jsdelivr.net/npm/faunadb@latest/dist/faunadb-min.js" on:load={connectToFauna}></script>
</svelte:head>
<style>
main {
text-align: center;
padding: 1em;
margin: 0 auto;
}
</style>
A few things to note here:
- At the top, I’ve got four imports:
ProductMenu
,ShoppingCart
, andNav
(three Svelte components which we’ll create below), and our Fauna client store that we created above. - I’m using a
svelte:head
block down below. This allows for code to be injected in the head section of the compiled HTML. In this case, I’ve opted to use the Fauna JavaScript driver from a CDN, rather than attempting to install from npm and deal with a polyfill. - I have an
onload
parameter, so that once the Fauna driver is loaded, theconnectToFauna
function will run and instantiate the connection. Credit to the Svelte REPL I found here for this function. - One issue I ran into here was the domain. Depending on the region your Fauna database is in, you will need to customize this accordingly — each region group has its own subdomain that you must use, or else you’ll be confused why you can’t access any of your data.
- In addition to the domain, you’ll need to insert the secret key generated earlier for the Svelte database role. Fill that in where it says “INSERT_SECRET_HERE”.
- I have a
handlePageChange
function and an if block in the actual HTML to determine whether to display the menu or shopping cart. I’ll have more on that below.
Next, let’s jump over to the components folder and start scaffolding our layout. Our application will be pretty simple, with a navigation bar at the top to switch between the menu and the shopping cart/checkout process. Inside of the components folder, let’s go ahead and create the following blank files:
Nav.svelte
ProductMenu.svelte
MenuSection.svelte
MenuItem.svelte
ShoppingCart.svelte
We’ll begin with the Nav component, as this will allow us to jump back and forth between the product menu and shopping cart. Here’s the full code for Nav:
<script>
import { shoppingCart } from '../stores/shoppingCart';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
function changePage(newPage) {
if (newPage === "menu" || $shoppingCart.length > 0)
dispatch('changepage', {
newPage: newPage
});
}
</script>
<nav>
<h1>Svelte Bakery Demo</h1>
<a href="#" on:click={() => changePage("menu")}>Menu</a>
<a href="#" on:click={() => changePage("cart")}>Cart
{#if $shoppingCart.length > 0}({$shoppingCart.length} items){/if}
</a>
</nav>
<style>
nav {
display: flex;
color: #FFF;
background-color: rgb(14, 14, 46);
flex-direction: column;
align-items: center;
}
@media(min-width: 768px) {
nav {
flex-direction: row;
gap: 3rem;
}
}
h1 {
flex-basis: 50%;
}
a {
text-decoration: none;
color: #FFF;
font-weight: bold;
}
a:hover {
text-decoration: underline;
}
</style>
In the script section, I’m using createEventDispatcher
to dispatch a custom event to the parent (App.svelte
). When clicking on one of the links, changePage
is called, and that function will send up an event to the main app with the new page name. Recall this code above from App.svelte
, when we placed our Nav
component in the HTML:
<Nav on:changepage={handlePageChange} />
You can see that I’ve set it up to call the handlePageChange
function in App.svelte
when that changepage
event is dispatched. That will update the currentPage
variable in App.svelte
, which drives whether the end user sees the menu or cart.
In addition to the functionality around changing pages, you can also see another if block in Nav.svelte
. I’ve imported my shopping cart store and am using shoppingCart.length
in a couple places to determine whether to show the number of items in the cart in the navbar, as well as whether the shopping cart link should even do anything — we won’t direct the user to the cart if it is currently empty. Store variables must be prefixed with a dollar sign in order to access them, but otherwise they work just like regular variables for reading, which makes it very easy.
Next, we’ll get started on the actual menu of products. The ProductMenu component will be our wrapper for the menu, and it’ll contain a MenuSection component for each category that holds our items. Inside each MenuSection will be individual MenuItem components for each product. Let’s start at the highest level, ProductMenu, and work our way down:
<script>
import MenuSection from './MenuSection.svelte';
import { fauna } from '../stores/fauna';
let loaded = false;
let categories = [];
$: {
if ($fauna.client && !loaded) {
loaded = true;
$fauna.client.query(
$fauna.q.Paginate(
$fauna.q.Match(
$fauna.q.Index('all_categories')
)
)
)
.then((res) => {
categories = res.data;
})
}
}
</script>
<section>
<h2>Menu</h2>
{#each categories as category}
<MenuSection {category} />
{/each}
</section>
Here in ProductMenu.svelte
, I’m importing our Fauna client from its store, as well as the MenuSection component which will be the next level down. Then, I’ve instantiated a couple of variables, one to hold the load state and one to hold the category list. I also checked the load state because I don’t want to attempt to query Fauna until the client is ready to go.
I’m then using a reactive block (the $:
in Svelte indicates a code block that will run when something inside of it has changed, in this case when the $fauna.client
store has changed). This block will query the all_categories
index and will bring back the id
and name
of each category. I’m populating the categories array with this returned data. Down below, I’m using a Svelte each block to iterate through the categories array. Each category will spawn a MenuSection component with the category passed as a parameter to it. Let’s look at the MenuSection next.
<script>
import MenuItem from './MenuItem.svelte';
import { onMount } from 'svelte';
import { fauna } from '../stores/fauna';
export let category;
let [ ref, name ] = category;
let items = [];
onMount(async () => {
let x = await $fauna.client.query(
$fauna.q.Paginate(
$fauna.q.Match(
$fauna.q.Index('product_by_category'),
ref
)
)
);
items = x.data;
})
</script>
<div>
<h3>{name}</h3>
<div class="items">
{#each items as item}
<MenuItem {item} />
{/each}
</div>
</div>
<style>
.items {
display: flex;
gap: 2rem;
justify-content: center;
flex-wrap: wrap;
}
</style>
This code gives us one thing we haven’t seen yet in our previous Svelte components: the export let
statement. Export let defines a parameter that is passed into this component by its parent. In this case, we’ve defined category as something that will be passed in. You may have noticed that the MenuSection component was inserted in the ProductMenu with a <MenuSection {category} />
. Because the variable passed in is named category, and the component is expecting one named category, I was able to enclose category in braces and leave it at that. If the variable had a different name in ProductMenu
, say c
for instance, I would have had to type <MenuSection category={c} />
instead. This nice little bit of syntactic sugar is Svelte-specific.
Back to the MenuSection
component — we’re also using onMount
here. This is one of Svelte’s lifecycle methods and allows us to run code when the component has been mounted in the DOM. In contrast to ProductMenu
, we know that we’ll have an instantiated Fauna client by the time the MenuSection
is mounted, so we don’t have to use the reactive statement workaround we created earlier. In this case, we can cleanly call the Fauna client inside onMount
.
I’m using the product_by_category
index here, which lets us search by category_id
. Since we passed that data into the component along with the category name, we can query to get all of the products in a particular category. Then, I’m using another each block to iterate through and send each one to a MenuItem
component. Take a look at the last piece of the menu, MenuItem
:
<script>
import { shoppingCart } from "../stores/shoppingCart";
export let item;
let [ name, price ] = item;
function addToCart() {
shoppingCart.update(n => {
return [ ...n, {name: name, price: price}];
});
}
</script>
<div>
<h4>{name}</h4>
<strong> ${price.toFixed(2)}</strong>
<button on:click={addToCart}>Add to cart</button>
</div>
<style>
div {
display: flex;
flex-direction: column;
width: 90%;
border: 1px solid #000;
border-radius: 5px;
}
@media(min-width: 768px) {
div {
width: 15%;
}
}
button {
width: fit-content;
margin: 1rem auto;
}
</style>
In the script section, I’ve imported the shoppingCart
store, as we will write items to the cart here as they are added. I also once again have an export let
declaration, as the specific item object will get passed in from MenuSection
. Finally, I define the addToCart
function, which will add the item to the shoppingCart
store using the shoppingCart.update
method.
The HTML and CSS are pretty straightforward; I’ll just call specific attention to the on:click
declared on the button. This is Svelte’s syntax for putting an event handler on an element. Since the item name and price are scoped to the component, I don’t have to worry about passing parameters to the function, as those are already known.
We’re getting close to the end! Let’s now take a look at the cart and checkout processes. Here’s ShoppingCart.svelte
:
<script>
import { shoppingCart } from "../stores/shoppingCart";
import { fauna } from '../stores/fauna';
const reducer = (previousValue, currentValue) => previousValue + parseFloat(currentValue.price);
let total;
let customerName;
let confirmed = false;
$: total = $shoppingCart.reduce(reducer, 0);
function removeItem(idx) {
shoppingCart.update(n => {
if (idx === 0) return [...n.slice(1)];
if (idx === n.length - 1) return [...n.slice(0, n.length - 1)]
return [...n.slice(0, idx), ...n.slice(idx+1)]
})
}
function submitOrder() {
console.log(customerName);
$fauna.client.query(
$fauna.q.Create(
$fauna.q.Collection('Orders'),
{ data: { customerName: customerName, details: $shoppingCart, total: total } }
)
).then(() => {
confirmed = true;
})
}
</script>
<section>
<h2>Shopping Cart</h2>
<table>
<thead>
<tr>
<th>Item</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
{#each $shoppingCart as item, idx}
<tr>
<td>{item.name}</td>
<td>${item.price.toFixed(2)}</td>
<td>
<button on:click={() => removeItem(idx)}>Remove</button>
</td>
</tr>
{/each}
</tbody>
<tfoot>
<tr>
<td>Total</td>
<td>${total.toFixed(2)}</td>
<td></td>
</tr>
</tfoot>
</table>
{#if !confirmed}
<form on:submit|preventDefault={submitOrder}>
<label for="name">Name: </label>
<input type="text" id="name" bind:value={customerName} />
<input type="submit" />
</form>
{:else}
<div>Thank you very much for your order!</div>
{/if}
</section>
<style>
section, table {
margin: 0 auto;
}
table {
border: 1px solid #000;
border-collapse: collapse;
}
td, th {
padding: 1rem;
}
tr:nth-child(even) {
background: rgb(172, 170, 170);
}
thead, tfoot {
font-weight: bold;
}
</style>
We’re importing both of our stores this time, as we’ll need shoppingCart
to read the data we’ve put in the cart, and we’ll need our Fauna driver to create a new document in the Orders collection. I’m using a reducer to sum the prices for all items in the cart and give me a single grand total value.
I’ve built a table in the HTML section that will pull in my shopping cart details. Each row also has a button that calls a removeItem
function, which will delete that item from the cart. In contrast to the each blocks I’ve used earlier, I also used an index on this one, as that allows me to track the index for each row in the table, so that I can use that value to remove the right record from my shoppingCart
store.
Finally, I have a conditional section that will either show an ordering form if the order has not been submitted, or a confirmation text if it has. Submitting the form calls submitOrder
, which will use the Create
function in Fauna to add a new document to our Orders collection (recall that the role we made earlier allows this). I’m using Svelte’s form binding to bind the name input to the customerName variable, which makes working with forms extremely smooth.
After a test run of the shopping cart, here’s an example of final data in the Orders collection:
{
"ref": Ref(Collection("Orders"), "321462934244950082"),
"ts": 1642829794070000,
"data": {
"customerName": "Alexander Popoutsis",
"details": [
{
"name": "Potato Chips",
"price": 1.25
},
{
"name": "Potato Salad",
"price": 2.99
},
{
"name": "Soup of the Day",
"price": 3.99
}
],
"total": 8.23
}
}
Next Steps
If you’d like to see the full code in one place, it’s on GitHub.
This is, of course, a basic demonstration, and there are many more things that could be done to make it more advanced (for example, handling item quantities and improving the styling). In addition, since this is a fully client-side app, the Fauna key is visible to end users, and security is especially important. To add a server-side component, you could use SvelteKit and hide the Fauna pieces behind a SvelteKit API endpoint.
You now have everything you need to get started with a basic app, using the fantastic combination of Fauna and Svelte. If you haven’t already, click here to get signed up with a Fauna account. And if you get stuck at all, the Fauna community is a fantastic resource as well.
Enjoyed this post? Consider buying me a coffee to fuel the creation of more content.