Content from Shopify
Read products, collections, vendors, blogs, articles, pages and metaobjects into Astro content collections, at build time or on every request, with the fields each one has.
Smootify turns your Shopify store into typed Astro content collections. A loader fetches the content, and getCollection, getEntry and getEntries work across it: a product knows its vendor and collections, an article knows its blog. You choose, for each collection, between loading it at build time and loading it on each request.
Note
The site needs its siteId in smootify(): see Install Smootify in Astro. The loaders read the store and the Storefront token from the site.
The loaders
| Shopify | Build loader | Live loader |
|---|---|---|
| Products | smootifyProducts | smootifyLiveProducts |
| Collections | smootifyCollections | smootifyLiveCollections |
| Vendors | smootifyVendors | smootifyLiveVendors |
| Blogs | smootifyBlogs | smootifyLiveBlogs |
| Articles | smootifyArticles | smootifyLiveArticles |
| Pages | smootifyPages | smootifyLivePages |
| Metaobjects | smootifyMetaobjects({ type }) | smootifyLiveMetaobjects({ type }) |
Build loaders come from @smootify/astro, live loaders from @smootify/astro/live. Vendors are not a Shopify object: they are derived from the vendor of the products.
Products, collections and vendors need the Storefront scope unauthenticated_read_product_listings. Blogs, articles and pages need unauthenticated_read_content, and metaobjects unauthenticated_read_metaobjects. The token of a Smootify site does not have the content scope: for blogs, articles and pages, pass your own store and storefrontToken. A metaobject definition needs Storefront access in Shopify.
1. Build or live
| Build | Live | |
|---|---|---|
| Where | src/content.config.ts | src/live.config.ts |
| Page | static: getStaticPaths | on demand: prerender = false |
| Read with | getCollection, getEntry | getLiveCollection, getLiveEntry |
| Data is | as of the last build | as of the request |
| Cost | none at request time | one Storefront call for each request that is not cached |
Choose build for a catalog and content that change rarely, or that you rebuild when the store changes. Choose live when prices, stock or content must be right on the server at the moment of the request. You can mix them: static catalog pages from the build collections, and a live product only on the product page.
Prices and stock in the browser stay fresh either way: Smootify updates them at runtime. Live collections are for the HTML the server sends.
2. Build collections
// src/content.config.ts
import { defineCollection } from "astro:content";
import { smootifyProducts, smootifyCollections, smootifyVendors } from "@smootify/astro";
const siteId = "<24-character Smootify site id>";
export const collections = {
products: defineCollection({ loader: smootifyProducts({ siteId, metafields: ["material"] }) }),
collections: defineCollection({ loader: smootifyCollections({ siteId, withProducts: true }) }),
vendors: defineCollection({ loader: smootifyVendors({ siteId }) }),
};A page for each entry, with getStaticPaths:
---
// src/pages/products/[slug].astro
import { getCollection, getEntry, getEntries } from "astro:content";
export async function getStaticPaths() {
const products = await getCollection("products");
return products.map((product) => ({ params: { slug: product.id }, props: { product } }));
}
const { product } = Astro.props;
const p = product.data;
const vendor = p.vendor ? await getEntry(p.vendor) : undefined;
const collections = (await getEntries(p.collections)).filter(Boolean);
---
<h1>{p.name}</h1>
<p>{p.priceFormatted}</p>An entry that points to something not in the store, such as a product that is not published to the Storefront API channel, resolves to undefined. Filter it out.
The loaders have these options in common:
| Option | Default | What it does |
|---|---|---|
siteId | SMOOTIFY_SITE_ID | Credentials of the site. |
store, storefrontToken | none | Your own store and Storefront token. They win over siteId. |
country, language | the store’s defaults | Prices and content of that market and language. |
metafields | none | Metafields to load, as "material", "ns.key" or { namespace, key }. |
formatMoney | the runtime’s format | Your own (amount, currencyCode) => string for the *Formatted fields. |
withProducts | false | Collections only: add the ordered products of each collection. |
A metafield needs Storefront access on its definition. In the entry, it is data.metafields.<key>, parsed by its type: booleans, numbers, JSON and lists become values, a file reference becomes a URL, a product or collection reference becomes a handle.
On the next build, unchanged entries are not rewritten and entries that left Shopify are removed.
3. Live collections
// src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { smootifyLiveProducts, smootifySchemas } from "@smootify/astro/live";
const siteId = "<24-character Smootify site id>";
export const collections = {
products: defineLiveCollection({
loader: smootifyLiveProducts({ siteId }),
schema: smootifySchemas.product({ siteId }),
}),
};---
// src/pages/products/[slug].astro
import { getLiveEntry } from "astro:content";
export const prerender = false;
const { entry, error, cacheHint } = await getLiveEntry("products", { id: Astro.params.slug! });
if (error) return new Response("Shopify error", { status: 502 });
if (!entry) return new Response(null, { status: 404 });
Astro.cache.set(cacheHint);
Astro.cache.set({ maxAge: 300, swr: 60 });
---
<h1>{entry.data.name}</h1>
<p>{entry.data.priceFormatted}</p>- Errors come back as
{ error }, never thrown. A missing entry isentry: undefinedwith no error. cacheHintholds the tags and the last-modified date of the entry.maxAgeandswrare yours to set. The tags are what lets a webhook refresh just the pages it touches: see Deploy to Cloudflare.- On demand needs the cache provider in
astro.config.mjs:cache: { provider: cacheCloudflare() }, withcacheCloudflarefrom@astrojs/cloudflare/cache. - The id is the same as in the build collection.
The products of a collection are not refs you can resolve with getEntries. Ask for them with the collection filter, all of them, in the collection’s order:
const { entries: products } = await getLiveCollection("products", { collection: handle });getLiveCollection takes filters: first on all loaders; query, sortKey, reverse and collection on products; blog on articles. A live vendor lookup pages through the whole catalog on every call, so keep that page behind the cache.
4. Fields
The three main collections have these fields in data.
Products
- Core.
name,slug(the cleaned handle, and the entry id),shopifyId(the full gid),description(HTML),image,imageAlt,isGiftCard,productType,seoTitle,seoDescription. - Prices.
price,priceFormatted,compareAtPrice,compareAtPriceFormatted(the first variant),priceRange,compareAtPriceRange,currencyCode. Formatted values follow the store’s money format with the market’s symbol, such as€380,00, so the page does not change when Smootify starts. - Links.
vendor(a reference to the vendor),collections(references). - Catalog.
options,variants,images,media,tags,availableForSale,totalInventory,defaultVariantId,vendorName,handle,seo,metafields.
Collections
name,slug,shopifyId,description,image,imageAlt,seoTitle,seoDescription,metafields,seo.products(references in the collection’s order) only withwithProducts: true.
Vendors
name,slug,products(references) andproductCount.
Blogs, articles, pages and metaobjects follow the same pattern: name, handle, shopifyId, seo and the content of the type (contentHtml, author and blog for an article; body for a page; fields for a metaobject, parsed by Shopify type). Product references in a metaobject become references to the product collection.
A metaobject loader is one collection for one type. The Storefront API does not expose its definition, so pass a schema (a Zod object) for fields, or the fields stay untyped.
5. SEO helpers
Every page puts <Seo> in its <head>. It writes the title, description, canonical, Open Graph and Twitter tags and the JSON-LD of the type:
---
import Seo from "@smootify/astro/components/Seo.astro";
---
<head>
<Seo type="product" entry={product} siteName="My shop" />
</head>The types are home, product, collection, vendor, article, blog, page and other (use noindex on pages that must not be indexed). The JSON-LD functions (productJsonLd, collectionJsonLd, breadcrumbJsonLd, articleJsonLd) are also exported. Canonical and alternate addresses follow Astro’s trailingSlash.
Before you publish
Check these first
- A blog, article or page loader fails with
ACCESS_DENIED. The Smootify token has no content scope. Pass your ownstoreandstorefrontTokenwithunauthenticated_read_content. - A metaobject comes back empty. The definition has no Storefront access in Shopify.
- A metafield is missing from
data.metafields. It is not in themetafieldsoption, it has no value, or its definition lacks Storefront access. Two namespaces with the same key collide: the last wins. - A live page shows old data. It has no
maxAge, or it reads a build collection. UsegetLiveEntryon pages that must be fresh. - A reference crashes the page. It points to something not published to the Storefront API. Filter the result of
getEntrieswith.filter(Boolean)and check a vendor before you read it. - A product is missing. Two products with the same cleaned slug: the first wins.