# 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.

Status: Ready (Smootify 2.0)  
URL: https://www.smootify.io/docs/astro/start/content  
Updated: 2026-10-21

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](https://www.smootify.io/docs/astro/start/install). 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

```ts
// 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`:

```astro
---
// 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

```ts
// 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 }),
  }),
};
```

```astro
---
// 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 is `entry: undefined` with no error.
- `cacheHint` holds the tags and the last-modified date of the entry. `maxAge` and `swr` are yours to set. The tags are what lets a webhook refresh just the pages it touches: see [Deploy to Cloudflare](https://www.smootify.io/docs/astro/start/deploy).
- On demand needs the cache provider in `astro.config.mjs`: `cache: { provider: cacheCloudflare() }`, with `cacheCloudflare` from `@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:

```ts
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 with `withProducts: true`.

**Vendors**

- `name`, `slug`, `products` (references) and `productCount`.

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:

```astro
---
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

> [!STUCK]
> - **A blog, article or page loader fails with `ACCESS_DENIED`.** The Smootify token has no content scope. Pass your own `store` and `storefrontToken` with `unauthenticated_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 the `metafields` option, 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. Use `getLiveEntry` on pages that must be fresh.
> - **A reference crashes the page.** It points to something not published to the Storefront API. Filter the result of `getEntries` with `.filter(Boolean)` and check a vendor before you read it.
> - **A product is missing.** Two products with the same cleaned slug: the first wins.
