Slugifying Stripe product ids for prettier, more SEO-friendly URLs
/shop/prod_Xfjs9S3fkdv is ugly. /shop/a-cool-shirt is not ugly. Stripe is great, but its API and SDK only allow you to fetch products by their ids - we can use a simple key-value store to map our slugged product names to their corresponding Stripe product ids. Yes, there are plenty of fully-managed shop services like Shopify who will do this for you already, but these platforms come with a cost and can be overkill if you just need a light e-commerce integration. For the price of one tiny extra fetch, in my opinion it makes your Stripe-backed product pages feel much more professional.
All the major cloud providers will have equivalent offerings, but I decided to go with Netlify in this case. I'm building on Astro, which is near enough plug and play with Netlify, and I liked the look of its simple storage solution 'Blob'. If you know me, you know that I will grasp any opportunity to play with something new with both hands (often by way of procrastination).
Context & Alternatives
In my case, the site I'm working on is backed by TinaCMS. Of course, my first thought was to leverage Tina to implement this, but I was put off by having two sources of truth (Stripe and Tina) and didn't want site admins to have to update things in two places. It's really your choice whether you want Stripe or your CMS to be the source of truth about your products - in my opinion to make this decision you should ask yourself the following:
- If your products are complex and you want a flexible schema, use your CMS - but you'll have to write more code.
- If your shop is pretty small and your products relatively simple, use Stripe.
- If you want subscriptions, use Stripe.
The reason you can do either is because Stripe checkout allows you to pass either price ids or inline product info - see below:
// price ids
const session = await stripe.checkout.sessions.create({
line_items: [
{
price: "price_1MotwRLkdIwHu7ixYcPLm5uZ",
quantity: 2,
},
],
mode: "payment",
});
// inline cart info
const session = await stripe.checkout.sessions.create({
line_items: [
{
price_data: {
currency: "usd",
product_data: {
name: "T-shirt",
},
},
quantity: 2,
},
],
mode: "payment",
});
This flexibility is extremely helpful, but be mindful if you're passing cart data inline you'll have to sanitise anything coming from the client.
I opted to use Stripe as the source of truth for my products; I thought the client in this case would appreciate the product & sales analytics Stripe offers and not be put off having to manage their site data in the CMS but deal with the shop on Stripe.
Getting Started
The goal is to populate a key-value store on Netlify Blob with entries where the key is a slugged version of the product's name and the value is its Stripe product id. We can then use this slug to generate nice URLs for our product pages in our app, leveraging Astro's on-demand server-side rendering.
Netlify Functions and Blob are extremely simple to get started with, I didn't have to modify the example netlify.toml
at all. I created the /netlify/functions
directory and started with a file called stripe-product-webhook-handler.mts
. I opted to use Stripe Webhooks to run this function whenever products are created, updated or deleted on my Stripe account and add the entry in to my key-value store. This is totally optional, you could just run a script in a GitHub Action or some other time to fetch all your Stripe products and populate the Blob store.
// stripe-product-webhook-handler.mts
import { getStore } from "@netlify/blobs";
import type { Context } from "@netlify/functions";
import slugify from "slugify";
export default async (req: Request, context: Context) => {
const json = await req.json();
const product = json.data.object;
const eventType = json.type; // product.created, product.updated, product.deleted
const products = getStore("stripe-products"); // creates the store if it doesn't already exist
if (eventType === "product.deleted") {
await products.delete(slugify(product.name));
} else {
await products.set(slugify(product.name), product.id);
}
return new Response("Submission saved");
};
The Netlify CLI is really easy to get started with, I won't detail connecting everything up and deploying here. I used their GitHub integration to redeploy the site every time I push to my main branch.
Note: When running things locally with netlify dev
is that you won't be connected to your live Blob store. To deal with this, I wrote a simple script like the one I mentioned above that you could run in a GitHub Action, which fetched my Stripe products and populated the local store if NODE_ENV === dev
on my shop page.
Usage
Now we've got our lovely slugs, let's put them to use on our shop page:
// shop/index.astro
// ---
import { actions } from "astro:actions";
import ShopPage from "../../layouts/ShopPage.astro";
export const prerender = false; // fetch products on demand
const products = await Astro.callAction(actions.fetchAllSluggedProducts, {});
// SET UP BLOBS
if (import.meta.env.NODE_ENV === "dev") {
await Astro.callAction(actions.setUpBlobs, {});
}
// ---
<ShopPage>
<Fragment slot="body">
<p>this is the shop</p>
<div class="product-grid">
{
products.data?.map((product) => (
<a href={`/shop/${product.slug}`}>
<div class="product">
<h1>{product.name}</h1>
<img src={product.images[0]} />
</div>
</a>
))
}
</div>
</Fragment>
</ShopPage>
You can see that fetchAllSluggedProducts
is obviously doing some heavy lifting here, but essentially it's returning the Stripe products along with their slug, which we can use to generate the product's pretty url. I used Astro actions for fetchAllSluggedProducts
, but you could also use an API route.
// fetchAllSluggedProducts.ts
import Stripe from "stripe";
import { getStore } from "@netlify/blobs";
export default async function fetchAllSluggedProducts(): Promise<Array<Stripe.Product & { slug: string }>> {
// get all items from blob store
const store = getStore("stripe-products");
const { blobs } = await store.list(); // doesn't return key along with the blob value
const promises: Promise<{ slug: string; pid: string }>[] = [];
blobs.map((blob) => {
promises.push(
store.get(blob.key).then((result) => {
return {
slug: blob.key,
pid: result,
};
}),
);
});
const slugPidMap = await Promise.all(promises);
// get products from stripe
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
const products = await stripe.products
.list({
ids: slugPidMap.map((entry) => entry.pid),
limit: slugPidMap.length,
active: true,
})
.then((result) => {
return result.data.map((product) => {
return {
...product,
slug: slugPidMap.find((entry) => entry.pid === product.id)?.slug ?? "",
};
});
});
return products;
}
Tip: Remember to update actions/index.ts
when you write a new Astro action!
The return type for this action says it all: Promise<Array<Stripe.Product & { slug: string }>
- return a promise containing an array of Stripe products with an extra key called 'slug' whose value is a string. At first I was fetching all the products from Stripe and then using the same 'slugify' package, but thought in the case that we end up with a mismatch between the Blob store and Stripe that it would be best to fetch the products by their slugs first and then marry them up to their Stripe products.
Now, on the /shop/[slug].astro
page, we need to fetch the product using its slug. For this I did decide to use an API route, as GET /api/product/[slug]
just felt appropriate. As above, you could absolutely use an Astro action here instead or any other flavour of server-side code.
// /shop/[slug].astro
// ---
import ShopPage from "../../layouts/ShopPage.astro";
const { slug } = Astro.params;
export const prerender = false; // on-demand rendering
const productRes = await fetch( `${new URL("/api", Astro.url)}/product/${encodeURIComponent(slug ?? "")}`, );
if (productRes.status !== 200) {
Astro.redirect("/not-found");
}
const product = await productRes.json();
// ---
<ShopPage>
<Fragment slot="body">
<h2>{product.name}</h2>
<img src={product.images[0]} />
</Fragment>
</ShopPage>
The API route itself just fetches the product id from the blob store and also fetches the Stripe product. You could fetch the product on the client if you wanted with Stripe.js (their client-side SDK).
// /api/product/[slug].ts
import type { APIRoute } from "astro";
import { getStore } from "@netlify/blobs";
import Stripe from "stripe";
export const prerender = false; // server endpoint
export const GET: APIRoute = async ({ params, request }): Promise<Response> => {
const slug = params.slug;
if (!slug) {
return new Response("Bad Request", { status: 400 });
}
const blobStore = getStore("stripe-products");
const pid = await blobStore.get(decodeURIComponent(slug));
// fetch product from Stripe
const stripe = new Stripe(import.meta.env.STRIPE_SECRET_KEY);
const product = await stripe.products.retrieve(pid);
if (!product) {
return new Response("Product not found", { status: 404 });
}
return new Response(
JSON.stringify({
...product,
slug,
}),
{ status: 200 },
);
};
Now we should have a fully working example with pretty slugified (slugged? slugdicated? sl*gged?) URLs being synced to Stripe, generated and fetched efficiently on our Astro site. I'll be bookmarking this method for future reference to make setting up shops using Stripe in future easy and producing a polished, SEO-optimised result.