Welcome! I’m César, a Front-end Architect with Lightspace. In this blog post we explore how to replace functionality from Shopify’s Script Editor App, which is set to be deprecated in 2025. In this post, I’ll walk you through how to leverage Shopify Functions to recreate key functionality—specifically, how to limit the number of items that can be added to the cart based on the customer’s location.
Requirements
Creating the app
In the directory where we want to work we run the following command:
shopify app init
It will ask you some questions, like the name of the app and if you want to start with Remix. Since we don’t need to build any UI we’ll select “Start by adding your first extension”.
It will generate a NodeJS project with some configuration files. If you’ve worked with any Front-end frameworks or NodeJS itself you’ll find yourself at home. It adds a shopify.app.toml config file with some Shopify configuration.
Creating the extension
Once the app is created we can then create the extension. An app can be made up of multiple extensions. In this case it’s just going to be one app with only one extension for now.
To generate the extension we run the following command:
shopify app generate extension
Again, it will ask us some questions, for instance the name:
But most importantly it will ask which type of extension we want. This is great since the differences between extensions are quite vast, since some of them use ReactJS to embed components and some others, like our case, don’t have an interface and run in the backend to validate some inputs. We choose Cart and Checkout Validation - Function:
At Lightspace we value code maintainability, so naturally we choose TypeScript in order to have Type checking validation at compile time:
Now the extension is generated and we are ready to do some coding!
Installing the App
This is the current structure once we open the app in our favorite IDE (in my case Webstorm).
We can see some interesting technologies already… Vite and Graphql (Prettier I added since I refuse to code without a formatter and you should too!). Vite is used to bundle the app and Graphql is used to interact with Shopify’s API. I think it’s great tooling and a very modern approach to extend Shopify’s functionality. It kind of took me by surprise since you wouldn’t expect big platforms like Shopify to invest in such modern technologies, so it was a great experience and I’m looking forward to seeing how they keep improving their platform.
In order to start the dev server you have to run the following command:
shopify app dev
It prompts us to select which development store we want to test the app with, in my case I’m using my dev store dev-cesalberca.myshopify.com.
Next it gives us several links:
- Preview URL: here we can interact with the app and run the code from the extension
- Graphiql URL: with this link we can run queries and mutations against the actual store. Using this interface we can create products, orders, add things to the cart and a ton of things
If we follow the Preview URL we will be asked to install the app:
Coding the app
When we generate the project we have the file run.ts, where we have the following contents:
import type { RunInput, FunctionRunResult, FunctionError } from '../generated/api'
export function run(input: RunInput): FunctionRunResult {
const errors: FunctionError[] = input.cart.lines
.filter(({ quantity }) => quantity > 1)
.map(() => ({
localizedMessage: 'Not possible to order more than one of each',
target: '$.cart',
}))
return {
errors,
}
}
And we even have some tests:
import { describe, it, expect } from 'vitest'
import { run } from './run'
import { FunctionRunResult } from '../generated/api'
describe('cart checkout validation function', () => {
it('returns an error when quantity exceeds one', () => {
const result = run({
cart: {
lines: [
{
quantity: 3,
},
],
},
})
const expected: FunctionRunResult = {
errors: [
{
localizedMessage: 'Not possible to order more than one of each',
target: '$.cart',
},
],
}
expect(result).toEqual(expected)
})
it('returns no errors when quantity is one', () => {
const result = run({
cart: {
lines: [
{
quantity: 1,
},
],
},
})
const expected: FunctionRunResult = { errors: [] }
expect(result).toEqual(expected)
})
})
This is a great starting point, let’s apply TDD so we start with modifying the tests in order to implement them later:
import {
describe
, expect,
it
} from 'vitest'
import { run } from './run'
import { CountryCode } from '../generated/api'
describe
('cart checkout validation function', () => {
const createError = (limit: number, country: string) => ({
errors: [
{
localizedMessage: `You cannot order more than ${limit} items when shipping to ${country}.`,
target: '$.cart',
},
],
})
it
.
each
([
['returns an error when Peru exceeds limit', CountryCode.
Pe
, 5, createError(4, 'Peru')],
['returns no errors when quantity is below limit for Peru', CountryCode.
Pe
, 3, { errors: [] }],
['returns no errors when quantity is equal limit for Peru', CountryCode.
Pe
, 4, { errors: [] }],
['returns an error when Chile exceeds limit', CountryCode.
Ch
, 11, createError(10, 'Chile')],
['returns no errors when quantity is within limit for Chile', CountryCode.
Ch
, 9, { errors: [] }],
['returns no errors when quantity is equal limit for Chile', CountryCode.
Ch
, 10, { errors: [] }],
['returns no errors when quantity is whatever for other countries', CountryCode.
Us
, 25, { errors: [] }],
])('%s', (_, isoCode, quantity, expected) => {
const input = ({
localization: {
country: {
isoCode,
},
},
cart: {
lines: [
{
quantity,
},
],
},
})
const result = run(input)
expect(result).toEqual(expected)
})
})
If we run the tests we’ll see they will fail. That’s great, this gives us the opportunity to handle case by case step by step.
First we need to find out the quantity of items in the cart. This we can do by reducing cart lines and adding their quantity together:
import type { RunInput, FunctionRunResult, FunctionError } from '../generated/api'
export function run(input: RunInput): FunctionRunResult {
const errors: FunctionError[] = []
const totalQuantity = input.cart.
lines
.reduce((sum, line) => sum + line.
quantity
, 0)
if (totalQuantity > 2) {
errors.push({
localizedMessage: `You cannot order more than 4 items when shipping to Peru.`,
target: '$.cart',
})
}
return {
errors,
}
}
This will make our first test pass. Yay! You might double check and perhaps notice that this code is not at all what we want in the end, however, following a step by step approach gives us a lot of benefits, like for example not adding more code than we need in order to keep things simple.
Now we make a commit with git and we keep at it, we need to get the localization. We do that by modifying the run.graphql file and get the isoCode:
query RunInput {
localization {
country {
isoCode
}
}
cart {
lines {
quantity
}
}
}
With this we get the isoCode from the client. This also works if they change the shippingAddress on the checkout dropdown. If we modify this code we need to also update the types with the following command:
npm run typegen
With this we can update the code and get the isoCode:
import type { RunInput, FunctionRunResult, FunctionError } from '../generated/api'
export function run(input: RunInput): FunctionRunResult {
const errors: FunctionError[] = []
const totalQuantity = input.cart.
lines
.reduce((sum, line) => sum + line.
quantity
, 0)
const countryCode = input.localization.
country
.
isoCode
if (totalQuantity > 2 && countryCode === 'PE') {
errors.push({
localizedMessage: `You cannot order more than 4 items when shipping to Peru.`,
target: '$.cart',
})
}
return {
errors,
}
}
With this we have some tests passing, although not for the best reason, since we are not handling Chile:
Now we need to implement the last bit of logic:
import type { RunInput, FunctionRunResult, FunctionError } from '../generated/api'
export function run(input: RunInput): FunctionRunResult {
const errors: FunctionError[] = []
const totalQuantity = input.cart.
lines
.reduce((sum, line) => sum + line.
quantity
, 0)
const countryCode = input.localization.
country
.
isoCode
if (countryCode === 'PE') {
const limit = 4
const name = 'Peru'
if (totalQuantity > limit) {
errors.push({
localizedMessage: `You cannot order more than ${limit} items when shipping to ${name}.`,
target: '$.cart',
})
}
} else if (countryCode === 'CH') {
const limit = 10
const name = 'Chile'
if (totalQuantity > limit) {
errors.push({
localizedMessage: `You cannot order more than ${limit} items when shipping to ${name}.`,
target: '$.cart',
})
}
}
return {
errors,
}
}
When we run the tests they are all green!
Now it’s the perfect time to make a commit and take the opportunity to refactor:
import type { RunInput, FunctionRunResult, FunctionError } from '../generated/api'
const countryLimits: { [key: string]: { limit: number; name: string } } = {
CH: { limit: 10, name: 'Chile' },
PE: { limit: 4, name: 'Peru' },
}
export function run(input: RunInput): FunctionRunResult {
const errors: FunctionError[] = []
const totalQuantity = input.cart.
lines
.reduce((sum, line) => sum + line.
quantity
, 0)
const countryCode = input.localization.
country
.
isoCode
if (countryCode && countryLimits[countryCode]) {
const { limit, name } = countryLimits[countryCode]
if (totalQuantity > limit) {
errors.push({
localizedMessage: `You cannot order more than ${limit} items when shipping to ${name}.`,
target: '$.cart',
})
}
}
return {
errors,
}
}
To make sure we didn’t break anything we run the tests again:
Testing the app
In order to test the app we need to follow the provided link when we run the command shopify app dev. It will ask us to install the app and then w
As we can see once the Country changes or the cart quantities change the validation gets triggered.
It also works on the cart section!
Conclusion
It’s just great to be able to extend a platform using your tools. Us developers spend so much time perfecting and improving our tooling that whenever I needed to develop a functionality on a web editor I feel limited. Shopify’s Function ecosystem provides a great integration point with modern, tested technologies that make my life easier. And that to me, it’s very important. How important is it to you?
About the author
César is a Senior Front-end Architect & Software Crafter from Spain with over 10 years of experience. Freelancer obsessed with best practices, architecture and testing.
As an international speaker and Digital Nomad, he shares my tech adventures around the world. Active in the Codemotion community as a committee member and ambassador. Host of Colivers Club Podcast. Check out his website for more information.