Use Next.js 13 With TypeScript To Create a Headless Shopify App
Andrew Shearer
Posted on August 29, 2023
This doc will show you how to set up a simple ecommerce app using Next.js 13 and Shopify as a headless ecommerce platform. We will use the following:
- Next.js w/ TypeScript
- GraphQL
- Shopify
Our goal for this simple demo will be to fetch products from Shopify and display them on the homepage. Then we will add dynamic routing to go to an individual product page, and view the details of that product there.
Initial Setup
Quickly download the latest Next.js TypeScript starter:
npx create-next-app@latest --ts .
Answer the questions like this:
✔ Would you like to use ESLint? … **No** / Yes
✔ Would you like to use Tailwind CSS? … No / **Yes**
✔ Would you like to use `src/` directory? … No / **Yes**
✔ Would you like to use App Router? (recommended) … No / **Yes**
✔ Would you like to customize the default import alias? … **No** / Yes
We need to correct some things before getting started. First, open tsconfig.json
and update it to this:
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "Node",
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Then open src/app/globals.css
. You might be getting an unknownAtRules
warning. If you are, follow these steps here to fix the issue.
Still in globals.css
, update the code with this reset from Josh Comeau:
/* src/app/globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
#root, #__next {
isolation: isolate;
}
Update src/app/page.tsx
to this:
// src/app/page.tsx
const HomePage = () => {
return (
<div>
<h1>Shopify + Next.js 13!</h1>
</div>
);
};
export default HomePage;
I like to use function expressions, so I will be using those throughout. If you prefer the function keyword, absolutely feel free to do so. All that matters is you pick one and stick with it.
Next, let’s update the root layout.tsx
to this:
// src/app/layout.tsx
import { Inter } from "next/font/google";
import type { Metadata } from "next";
import type { ReactNode } from "react";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
description: "Generated by create next app",
title: "Shopify + Next.js 13!"
};
type RootLayoutProps = {
children: ReactNode;
};
const RootLayout = ({ children }: RootLayoutProps) => {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
};
export default RootLayout;
Next, go to GitHub and create a repo (if you want). If you do, you only need to run the following 3 commands to push it up to the remote repo:
git branch -M main
git remote add origin <https://github.com/YOUR_USERNAME_HERE/GIT_LINK_HERE>
git push -u origin main
Store Setup
Go to Shopify Partners and create an account if you do not have one already. Once in your dashboard, click Stores. If you do not have any Stores, click Add store, and then select Create development store, and then go through the process of creating a dev store.
Just make sure to select Create a store to test and build
Shopify Products Setup
If you’re in need of some products, you can either get some dummy products from this repo, or you can download my small list of products which we’ll use for this demo.
Shopify Storefront API Setup
Enable custom app development
- From your Shopify admin, click Settings > Apps and sales channels.
- Click Develop apps.
- Click Allow custom app development.
- Read the warning and information provided, and then click Allow custom app development.
Create and install a custom app
- From your Shopify admin, click Settings > Apps and sales channels.
- Click Develop apps
- Click Create an app
- In the modal window, enter the App name and select an App developer
- Click Create app.
Select API scopes
- Click Configure Admin API scopes.
- In the Admin API access scopes section, select the API scopes that you want to assign to the app.
- For this demo, select at least
write_products
&read_products
under Products.
- For this demo, select at least
- Click Save
- Make sure to copy the Admin API access token as you can only see it once!
- Click Install App.
Configure Environment Variables
Back in the code, create a .env.local
file at the root level and add the following:
ADMIN_API_ACCESS_TOKEN=YOUR_TOKEN_HERE
Replace YOUR_TOKEN_HERE
with your Admin API access token
You do not need the API key or the API secret key
Shopify GraphiQL App Setup
- Go here to install the GraphQL API app
-
On this page:
-
Enter your Shop URL
- To get your shop url, when in the admin, click on home
- The URL should be something like:
https://admin.shopify.com/store/STORE_NAME_HERE
- Copy and paste whatever is your STORE_NAME_HERE
You can leave the Admin API and Storefront API checkboxes alone, or check more if you want
Click Install
Wait for the redirect to finish, and click Install app
-
Once finished, you should be in the GraphiQL playground with this query to start with:
{
shop {
name
}
}
- Run it and you should see something like this:
{
"data": {
"shop": {
"name": "Next.js Headless"
}
},
"extensions": {
"cost": {
"requestedQueryCost": 1,
"actualQueryCost": 1,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 999,
"restoreRate": 50
}
}
}
}
We want to query for products, so let's create a ProductsQuery.
Let's start off simple with getting just the product titles:
query ProductsQuery {
products(first: 6) {
nodes {
title
}
}
}
We must provide an argument to products()
. Typically it is eitherfirst
or last
Now we should see a result:
{
"data": {
"products": {
"nodes": [
{
"title": "Head Bust Digital Print"
},
{
"title": "Dripping Hand Digital Print"
},
{
"title": "Woman's Face Digital Print"
},
{
"title": "Purpe & Red Waves Digital Print"
},
{
"title": "Purple Rock Sculpture Digital Print"
},
{
"title": "Tree on a Hill Digital Print"
}
]
}
},
"extensions": {
"cost": {
"requestedQueryCost": 8,
"actualQueryCost": 3,
"throttleStatus": {
"maximumAvailable": 1000,
"currentlyAvailable": 843,
"restoreRate": 50
}
}
}
}
- Let's just display this data on the homepage for now, and we'll come back to it and add more
Displaying Simple Shopify Data
Let’s start off simple by fetching just the product titles for now to ensure everything is working correctly
Inside .env.local
file and add the following and update the placeholders accordingly:
GRAPHQL_API_URL=https://STORE_NAME.myshopify.com/admin/api/API_VERSION/graphql.json
- You get the
API_VERSION
while in the GraphiQL playground. There is a drop down calledAPI version
. You just need theyear-month
. Currently at the time of writing, the current version is2023-07
- And your
STORE_NAME
is this part of your store url: https://my-store-name.myshopify.com
To have Next.js load these variables, simply restart the dev server. You should see info - Loaded env from ...
in the terminal.
Open src/app/page.tsx
and add this above the component:
const getProducts = async () => {
// ...
}
In the src
folder, create a utils
folder
Inside the utils
folder, create a file called gql.ts
and add this to it:
export const gql = String.raw;
This gives us a nice wrapper around multi line template strings, making it much easier to construct / format our GraphQL queries.
Now let's fetch some data! First, let’s add this type above the function:
type GraphQLResponse = {
data: {
products: {
nodes: {
title: string;
}[];
};
};
extensions: {
cost: {
requestedQueryCost: number;
actualQueryCost: number;
throttleStatus: {
maximumAvailable: number;
currentlyAvailable: number;
restoreRate: number;
};
};
};
};
const getProducts = async () => {
// ...
}
Now let’s create the fetch call:
// src/app/page.tsx
type GraphQLResponse = {
// ...
}
const getProducts = async (): Promise<GraphQLResponse> => {
const res = await fetch(process.env.GRAPHQL_API_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": process.env.ADMIN_API_ACCESS_TOKEN!
},
body: JSON.stringify({
query: gql`
query ProductsQuery {
products(first: 6) {
nodes {
title
}
}
}
`
})
});
if (!res.ok) {
const text = await res.text(); // get the response body for more information
throw new Error(`
Failed to fetch data
Status: ${res.status}
Response: ${text}
`);
}
return res.json();
};
-
NOTE 1: For the
env
variables to work, you MUST put them inside template strings, or use the!
operator to note they are not null. Otherwise, you get a type error thatstring | undefined
does not work for typestring
. The “proper” approach would be to actually handle the undefined case, but this is just a quick demo, so I think this is ok for now. -
NOTE 2: For some reason, Shopify does not like when you have the
API_VERSION
as it's own env variable. Perhaps due to being a number, or enum? Just put the whole URL as a variable and it will be ok
Here is a link to the Shopify GraphQL docs if you want to check it out. Previously, the access token header was called X-Shopify-Storefront-Access-Token
. Do not use this! Ensure you are using the updated name as seen in the code above. I may or may not have gotten stuck on this for an unnecessary amount of time >_>
Our query is pretty straightforward. We use the GRAPHQL_API_URL
environment variable as the endpoint, make sure to use POST
for the method
, set the headers
as seen above, and then wrap the query in a JSON.stringify
. You could certainly abstract this out into its own function later on, if you prefer. Again, since this is a simple demo, it’s fine here for now.
Now let's update the page component to show the product titles in a list:
const HomePage = async () => {
const json = await getProducts();
return (
<main>
<h1>Shopify + Next.js 13!</h1>
<ul>
{json.data.products.nodes.map((product) => (
<li key={product.title}>{product.title}</li>
))}
</ul>
</main>
);
};
Here, we are leveraging the power of React Server Components, which is the default in Next.js 13. We don’t have to use getStaticProps
like we used to, or use the useEffect
hook. We can use plain old async/await with the standard fetch api. Which, to me at least, feels a lot better is a much simpler approach than in the past.
And if you go to the browser, you should see the list of product names on the page.
Getting and Display More Data
Now let's update our product query on the home page to show the description
, featuredImage
, handle
, price
, tags
, and title
But before we do, we need to add Shopify’s CDN to the list of domains in next.config.js
Otherwise, we’ll get an error and won’t be able to display the images. Shut down the local dev server, and update the code to this:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["cdn.shopify.com"]
},
reactStrictMode: true
};
module.exports = nextConfig;
After changing it, start the server back up again.
Next, let's update our GraphQL query to this:
query ProductsQuery {
products(first: 6) {
nodes {
description
featuredImage {
altText
height
id
url
width
}
handle
id
priceRangeV2 {
minVariantPrice {
amount
currencyCode
}
}
tags
title
}
}
}
The altText
for the images will be null
at first if you run this in the GraphiQL playground . To fix this, so go to
- Products, then select a product
- Click on the image
- Click add alt text
- Add something simple like
Preview for PRODUCT_NAME
- Click Save alt text
- Repeat this for all product images
Go back to Shopify GraphiQL App, and rerun the query. altText
should have a value now
Now is also a good time to setup some types for Shopify
In the src
directory, create another directory called types
, and inside there, create a file called index.ts
Add these types:
// src/types/index.ts
export type ShopifyExtension = {
cost: {
actualQueryCost: number;
requestedQueryCost: number;
throttleStatus: {
currentlyAvailable: number;
maximumAvailable: number;
restoreRate: number;
};
};
};
export type ShopifyProduct = {
description: string;
featuredImage: {
altText: string;
height: number;
id: string;
url: string;
width: number;
};
handle: string;
id: string;
priceRangeV2: {
minVariantPrice: {
amount: string;
currencyCode: string;
};
};
tags: string[];
title: string;
};
There are certainly way more fields than these in the Schema, but this is a good starting point based on our query / small demo.
Back in src/app/page.tsx
, we can update our GraphQLResponse
type to this:
import type { ShopifyExtension, ShopifyProduct } from "@/types";
type GraphQLResponse = {
data: {
products: {
nodes: ShopifyProduct[];
};
};
extensions: ShopifyExtension;
};
Looks a lot cleaner!
Next, we need to update our query in the fetch request
const getProducts = async (): Promise<GraphQLResponse> => {
const res = await fetch(process.env.GRAPHQL_API_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": process.env.ADMIN_API_ACCESS_TOKEN!
},
body: JSON.stringify({
query: gql`
query ProductsQuery {
products(first: 6) {
nodes {
description
featuredImage {
altText
height
id
url
width
}
handle
priceRangeV2 {
minVariantPrice {
amount
currencyCode
}
}
tags
title
}
}
}
`
})
});
// ...
};
Now we can update the JSX to this:
<main className="container mx-auto">
<div className="px-5">
<h2 className="font-bold text-2xl mb-3">Our Products:</h2>
<ul className="grid grid-cols-12 gap-4 pb-12">
{json.data.products.nodes.map((product) => {
const prodId = product.id.split("/").pop();
return (
<li
key={product.id}
className="border border-slate-200 rounded-md overflow-hidden col-span-full md:col-span-6 lg:col-span-4"
>
<div>
<Image
src={product.featuredImage.url}
alt={product.featuredImage.altText}
width={product.featuredImage.width}
height={product.featuredImage.height}
className="h-96 w-full object-cover"
placeholder="blur"
blurDataURL={product.featuredImage.url}
/>
</div>
<div className="p-5">
{product.tags.map((tag) => (
<span
key={tag}
className="bg-yellow-400 font-bold py-1 px-3 rounded-full text-xs"
>
{tag}
</span>
))}
<h3 className="font-medium mt-3 text-3xl">{product.title}</h3>
<h4>
{formatPrice(product.priceRangeV2.minVariantPrice.amount)}{" "}
{product.priceRangeV2.minVariantPrice.currencyCode}
</h4>
<p className="mt-2 mb-4">{product.description}</p>
<Link
href={`/product/${prodId}`}
className="border border-blue-600 inline-block p-2 rounded-md text-blue-600 hover:bg-blue-600 hover:text-white ease-in-out duration-150"
>
View Product
</Link>
</div>
</li>
);
})}
</ul>
</div>
</main>
WIth this update, we’ll need to import a few things from next
:
import Image from "next/image";
import Link from "next/link";
The price doesn't display correctly, so we can create a formatPrice
util function to do that for us:
// src/utils/formatPrice.ts
export const formatPrice = (price: string) =>
Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
minimumFractionDigits: 2
}).format(parseInt(price, 10));
Creating Product Pages
In our home page JSX, we have this link at the bottom:
<Link href={`/product/${prodId}`} className="...">
View Product
</Link>
Notice we are linking to a dynamic page using the product’s id. We had to format it a bit because otherwise the full id is this:
"gid://shopify/Product/PRODUCT_ID_HERE"
Which is not very url friendly. Previously we could use the handle
, but now id
with a type of ID
is the only accepted argument
To create this route, in the app directory we can create a directory called product
, then another directory inside there called [id]
. In the [id] directory, create a page.tsx
file. For now, let’s use this dummy JSX code:
// src/app/product/[id]/page.tsx
const SingleProductPage = () => {
return <div>SingleProductPage</div>;
};
export default SingleProductPage;
Now if we go back to the browser and click on any of the “View Product” buttons, it should take us to the page for that product
Now let’s flesh out the page some more to display the info for a given product. First, let's create our type:
// src/app/product/[id]/page.tsx
type GraphQLResponse = {
data: {
product: ShopifyProduct;
};
extensions: ShopifyExtension;
};
Notice the structure for data
is slightly different now that we are querying a single product instead of a group of products
Next, our getProduct
function:
// src/app/product/[id]/page.tsx
const getProduct = async (id: string): Promise<GraphQLResponse> => {
const res = await fetch(process.env.GRAPHQL_API_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Shopify-Access-Token": process.env.ADMIN_API_ACCESS_TOKEN!
},
body: JSON.stringify({
query: gql`
query SingleProductQuery($id: ID!) {
product(id: $id) {
id
title
}
}
`,
variables: {
id: `gid://shopify/Product/${id}`
}
})
});
if (!res.ok) {
const text = await res.text(); // get the response body for more information
throw new Error(`
Failed to fetch data
Status: ${res.status}
Response: ${text}
`);
}
return res.json();
};
In GraphQL, if we want to dynamically query something, we need to use variables like $id
here. Previously, we could use the type String!
, but that is no longer possible. We must now use the type ID!
. Not a big deal, just pointing out the change. And we’ll just fetch the product title and id for now to get started.
Furthermore, along with the query
property, we must also pass along the variables
property. Here, we must use the full "gid://shopify/Product/ID_HERE”
shown earlier. We cannot simply use the number portion, we must use the full string. We then interpolate the id
parameter into this id string.
With Next, we can access the id from the URL by using the params prop in our component. And as a quick note, whatever you call the dynamic segment, in this case id
, will be the property availabe in the params
object.
So in the component we can do this:
// src/app/product/[id]/page.tsx
type SingleProdutPageProps = {
params: {
id: string;
};
};
const SingleProductPage = async ({ params }: SingleProdutPageProps) => {
const json = await getProduct(params.id);
const { product } = json.data;
return (
<div>
<h1>View page for: {product.title}</h1>
</div>
);
};
And we should see the product title on the page. Now let’s update the query to get and display more data on the page!
But before we do that, the page is kind of awkward. It’s just the h1
with no navigation. Let’s set up a simple global Navbar
and Footer
. We’ll also move the main h1
from the home page into the nav
so the simple demo feels a bit more cohesive
Adding Global Nav & Footer
To get started, in the src
directory, create another directory called components
In this components
directory, create 2 files: Navbar.tsx
and Footer.tsx
Here is the code for the Navbar
:
// src/components/Navbar.tsx
import Link from "next/link";
const Navbar = () => {
return (
<nav className="mt-10">
<h1 className="font-bold mb-3 text-3xl text-center">Shopify + Next.js 13!</h1>
<ul className="text-center">
<li>
<Link href="/" className="text-blue-600 hover:underline">
Home
</Link>
</li>
</ul>
</nav>
);
};
export default Navbar;
Here is the code for the Footer:
// src/components/Footer.tsx
const Footer = () => {
return (
<footer className="bg-blue-600 mt-auto py-3 text-center text-white">
Shopify + Next.js 13!
</footer>
);
};
export default Footer;
Next, in the root layout.tsx
, let’s update the JSX to this:
// src/app/layout.tsx
const RootLayout = ({ children }: RootLayoutProps) => {
return (
<html lang="en">
<body className={`${inter.className} flex flex-col min-h-screen`}>
<Navbar />
{children}
<Footer />
</body>
</html>
);
};
With this in place, the footer will always be on the bottom of the page.
Single Product Page
First, let’s update our GraphQL query with the other pieces of data we want to display:
body: JSON.stringify({
query: gql`
query SingleProductQuery($id: ID!) {
product(id: $id) {
description
featuredImage {
altText
height
id
url
width
}
id
priceRangeV2 {
minVariantPrice {
amount
currencyCode
}
}
tags
title
}
}
`,
variables: {
id: `gid://shopify/Product/${id}`
}
})
Next let’s update the JSX for the single product page to this:
<div className="container mx-auto md:pb-10">
<div className="flex flex-col md:flex-row md:items-center">
<div className="md:basis-1/2">
<Image
src={product.featuredImage.url}
alt={product.featuredImage.altText}
width={product.featuredImage.width}
height={product.featuredImage.height}
placeholder="blur"
blurDataURL={product.featuredImage.url}
/>
</div>
<div className="p-5 md:basis-1/2">
{product.tags.map((tag) => (
<span key={tag} className="bg-yellow-400 font-bold py-1 px-3 rounded-full text-xs">
{tag}
</span>
))}
<h3 className="font-medium mt-3 text-3xl">{product.title}</h3>
<h4>
{formatPrice(product.priceRangeV2.minVariantPrice.amount)}{" "}
{product.priceRangeV2.minVariantPrice.currencyCode}
</h4>
<p className="mt-2 mb-4">{product.description}</p>
<button
className="border border-blue-600 inline-block p-2 rounded-md text-blue-600 hover:bg-blue-600 hover:text-white ease-in-out duration-150"
type="button"
>
Add to Cart
</button>
<span className="block mt-1 text-xs">* Note: this won't actually do anything</span>
</div>
</div>
</div>
And that’s it! You’re done!
Host on Vercel
Go to Vercel and create an account if you don't already have one. Once signed in, go to your dashboard.
Click + New Project, and select the project from GitHub (authorize if needed). Make sure to enter the environment variables! Click Deploy.
Next Steps
This was intended to be a starting point for using Next.js 13 and Shopify as a headless backend. Some things to implement going forward would be:
- Add to cart functionality
- Display products in a cart
- Going through checkout process
- User sign in / sign out
I hope you enjoyed this article! You can view my repo here to see the code for reference if you ever get stuck. Cheers!
Posted on August 29, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.