Skip to main content

Caching third-party API data with Hydrogen and Oxygen

Note

This guide might not be compatible with features introduced in Hydrogen version 2025-05 and above. Check the latest documentation if you encounter any issues.

The API client built into Hydrogen includes caching strategies for Storefront API data. However, if you make fetch requests to third-party APIs in your Hydrogen app, then the following behavior occurs:

  • HTTP GET responses are cached according to their response headers.

  • POST requests aren't cached.

    There are several ways to manage caching of third-party data with Hydrogen and Oxygen:

  1. Hydrogen’s built-in withCache utility (recommended)
  2. Creating custom abstractions
  3. Caching content manually
Note

If you host your Hydrogen app on another provider instead of Oxygen, then caching might work differently. Consult your provider for details on its caching capabilities.


Anchor to Hydrogen’s built-in withCache utilityHydrogen’s built-in withCache utility

Hydrogen includes a createWithCache utility to support caching third-party API calls. This utility wraps an arbitrary number of sub-requests under a single cache key.

Anchor to Step 1: Create and inject the utility functionStep 1: Create and inject the utility function

To start, create a withCache function in your project server file and pass it as part of the Remix context.

The following example shows how withCache works with Oxygen:

Create the withCache utility

/server.js

import {createStorefrontClient, createWithCache} from '@shopify/hydrogen';
import {createRequestHandler} from '@shopify/remix-oxygen';

export default {
async fetch(request, env, executionContext) {
const cache = await caches.open('hydrogen');
const waitUntil = (promise) => executionContext.waitUntil(promise);

const {storefront} = createStorefrontClient({
cache,
waitUntil,
// ...
});

// Create withCache object
const withCache = createWithCache({cache, waitUntil, request});

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
// Pass withCache to the Remix context
getLoadContext: () => ({storefront, withCache, waitUntil}),
});

return handleRequest(request);
},
};
import {createStorefrontClient, createWithCache} from '@shopify/hydrogen';
import {createRequestHandler} from '@shopify/remix-oxygen';

export default {
async fetch(request: Request, env: Env, executionContext: ExecutionContext) {
const cache = await caches.open('hydrogen');
const waitUntil = (promise: Promise<unknown>) => executionContext.waitUntil(promise);

const {storefront} = createStorefrontClient({
cache,
waitUntil,
// ...
});

// Create withCache object
const withCache = createWithCache({cache, waitUntil, request});

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
// Pass withCache to the Remix context
getLoadContext: () => ({storefront, withCache, waitUntil}),
});

return handleRequest(request);
},
};
/**
* For TypeScript projects, import Hydrogen’s included `withCache` types
* in the Remix context by adding them to your Remix type declaration file.
*/

import type {Storefront, WithCache} from '@shopify/hydrogen';

declare module '@shopify/remix-oxygen' {
export interface AppLoadContext {
storefront: Storefront;
withCache: WithCache;
waitUntil: (promise: Promise<unknown>) => void;
}
}

Anchor to Step 2: Call ,[object Object], in Remix loaders and actionsStep 2: Call withCache.fetch in Remix loaders and actions

After you pass the utility function to the Remix context, withCache is available in all Remix loaders and actions.

In the following example, the withCache.fetch function wraps a standard fetch query to a third-party CMS:

Cache a sub-request to a third-party API using withCache

/app/routes/pages/example.jsx

const CMS_API_ENDPOINT = 'https://example-cms.com/api';

export async function loader({request, context}) {
{ storefront, withCache } = context;
const query = `query { product { id } }`;

/**
* The cache key is used to uniquely identify the stored value in cache.
* If caching data for logged-in users, then make sure to add something
* unique to the user in the cache key, such as their email address.
*/
const cacheKey = [CMS_API_ENDPOINT, query];

const {data} = await withCache.fetch(
CMS_API_ENDPOINT, // URL to fetch
{ // Fetch options
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
},
{ // Caching options
cacheKey,
cacheStrategy: storefront.CacheLong(),
shouldCacheResponse: () => true, // withCache.fetch will only cache when response.ok
},
);

return {idFromCMS: data.product.id};
}
import type {LoaderFunctionArgs} from '@shopify/remix-oxygen';

const CMS_API_ENDPOINT = 'https://example-cms.com/api';

export async function loader({request, context: {storefront, withCache}}: LoaderFunctionArgs) {
const query = `query { product { id } }`;

/**
* The cache key is used to uniquely identify the stored value in cache.
* If caching data for logged-in users, then make sure to add something
* unique to the user in the cache key, such as their email address.
*/
const cacheKey = [CMS_API_ENDPOINT, query];

const {data} = await withCache.fetch<{product: {id: string}}>(
CMS_API_ENDPOINT, // URL to fetch
{ // Fetch options
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
},
{ // Caching options
cacheKey,
cacheStrategy: storefront.CacheLong(),
shouldCacheResponse: () => true, // withCache.fetch will only cache when response.ok
},
);

return {idFromCMS: data!.product.id};
}

Anchor to Custom cache abstractionsCustom cache abstractions

Instead of using withCache.fetch directly in your routes, you can also create custom abstractions around it. For example, you can make your own CMS fetcher and inject it in the Remix context.

You can create as many abstractions as needed for your third-party APIs, and they will be available in Remix loaders and actions. For TypeScript projects, you should add types accordingly in the remix.env.d.ts file.

Create a custom abstraction

/server.js

import {createStorefrontClient, createWithCache} from '@shopify/hydrogen';
import {createRequestHandler} from '@shopify/remix-oxygen';

export default {
async fetch(request, env, executionContext) {
const cache = await caches.open('hydrogen');
const waitUntil = (promise) => executionContext.waitUntil(promise);

const {storefront} = createStorefrontClient({
cache,
waitUntil,
// ...
});

const withCache = createWithCache({cache, waitUntil, request});

const fetchMyCMS = (
query: string,
cacheStrategy = storefront.CacheLong(),
) => {
const CMS_API_ENDPOINT = 'https://example-cms.com/api';
const cacheKey = [CMS_API_ENDPOINT, query];

return withCache.fetch(
CMS_API_ENDPOINT,
{
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
},
{
cacheKey,
cacheStrategy,
// Cache if there are no data errors or specific data that make this result not suited for caching
shouldCacheResponse: (result) =>
!(result?.errors || result?.isLoggedIn),
},
);
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({storefront, fetchMyCMS, waitUntil}),
});

return handleRequest(request);
},
};
import {createStorefrontClient, createWithCache} from '@shopify/hydrogen';
import {createRequestHandler} from '@shopify/remix-oxygen';

export default {
async fetch(request: Request, env: Env, executionContext: ExecutionContext) {
const cache = await caches.open('hydrogen');
const waitUntil = (promise: Promise<unknown>) => executionContext.waitUntil(promise);

const {storefront} = createStorefrontClient({
cache,
waitUntil,
// ...
});

const withCache = createWithCache({cache, waitUntil, request});

const fetchMyCMS = <My3PDataResponse>(
query: string,
cacheStrategy = storefront.CacheLong(),
) => {
const CMS_API_ENDPOINT = 'https://example-cms.com/api';
const cacheKey = [CMS_API_ENDPOINT, query];

return withCache.fetch<My3PDataResponse>(
CMS_API_ENDPOINT,
{
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
},
{
cacheKey,
cacheStrategy,
// Cache if there are no data errors or specific data that make this result not suited for caching
shouldCacheResponse: (result: My3PDataResponse) =>
!(result?.errors || result?.isLoggedIn),
},
);
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({storefront, fetchMyCMS, waitUntil}),
});

return handleRequest(request);
},
};

Alternatively, if you need to do include extra logic within the custom cache abstraction itself, there is withCache.run

Create a custom abstraction with withCache run

/server.js

import {createStorefrontClient, createWithCache} from '@shopify/hydrogen';
import {createRequestHandler} from '@shopify/remix-oxygen';

export default {
async fetch(request, env, executionContext) {
const cache = await caches.open('hydrogen');
const waitUntil = (promise) => executionContext.waitUntil(promise);

const {storefront} = createStorefrontClient({
cache,
waitUntil,
// ...
});

const withCache = createWithCache({cache, waitUntil, request});

const fetchMyCMS = (
query: string,
cacheStrategy = storefront.CacheLong(),
) => {
const CMS_API_ENDPOINT = 'https://example-cms.com/api';
const cacheKey = [CMS_API_ENDPOINT, query];

return withCache.run(
{
cacheKey,
cacheStrategy,
// Cache if there are no data errors
shouldCacheResult: (result) => result.errors.length === 0,
},
async () => {
const response = await fetch(CMS_API_ENDPOINT, {
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
});

if (!response.ok) return {errors: ['Something went wrong']};

const {product} = await response.json();

return {id: product.id, errors: []};
},
);
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({storefront, fetchMyCMS, waitUntil}),
});

return handleRequest(request);
},
};
import {createStorefrontClient, createWithCache} from '@shopify/hydrogen';
import {createRequestHandler} from '@shopify/remix-oxygen';

export default {
async fetch(request: Request, env: Env, executionContext: ExecutionContext) {
const cache = await caches.open('hydrogen');
const waitUntil = (promise: Promise<unknown>) => executionContext.waitUntil(promise);

const {storefront} = createStorefrontClient({
cache,
waitUntil,
// ...
});

const withCache = createWithCache({cache, waitUntil, request});

const fetchMyCMS = <My3PDataResponse>(
query: string,
cacheStrategy = storefront.CacheLong(),
) => {
const CMS_API_ENDPOINT = 'https://example-cms.com/api';
const cacheKey = [CMS_API_ENDPOINT, query];

return withCache.run<My3PDataResponse>(
{
cacheKey,
cacheStrategy,
// Cache if there are no data errors
shouldCacheResult: (result: My3PDataResponse) => result.errors.length === 0,
},
async () => {
const response = await fetch(CMS_API_ENDPOINT, {
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
});

if (!response.ok) return {errors: ['Something went wrong']};

const {product} = await response.json<{product: {id: string}}>();

return {id: product.id, errors: []};
},
);
};

const handleRequest = createRequestHandler({
build: remixBuild,
mode: process.env.NODE_ENV,
getLoadContext: () => ({storefront, fetchMyCMS, waitUntil}),
});

return handleRequest(request);
},
};

Anchor to Overriding default caching behaviorOverriding default caching behavior

By default, withCache.fetch will cache successful fetches (i.e. those where response.ok is true), and withCache.run will always cache the result. This may not always be desirable, for example a CMS query may technically respond with an ok status code, but the response data might still contain errors. In these cases, you can override the default caching behavior.

To override the default caching behavior for withCache.fetch, you need to use shouldCacheResponse, which takes 2 params: the response data, and the Response object itself:

Custom cache behavior for withCache fetch

/server.js

withCache.fetch(
CMS_API_ENDPOINT,
{
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
},
{
cacheKey,
cacheStrategy,
shouldCacheResponse: (result, response) =>
response.status === 200 && !result.isLoggedIn, // Do not cache if the buyer is in a logged in state
},
);
withCache.fetch<My3PDataResponse>(
CMS_API_ENDPOINT,
{
method: 'POST',
body: JSON.stringify({query}),
headers: {'Content-Type': 'application/json'},
},
{
cacheKey,
cacheStrategy,
shouldCacheResponse: (result: My3PDataResponse, response: Response) =>
response.status === 200 && !result.isLoggedIn, // Do not cache if the buyer is in a logged in state
},
);

To override the default caching behavior for withCache.run, you need to use shouldCacheResult, which only takes 1 param: the result of the inner function:

Custom cache behavior for withCache run

/server.js

withCache.run(
{
cacheKey,
cacheStrategy,
shouldCacheResult: (result) => result.errors.length === 0,
},
async () => {
...
},
);
withCache.run<My3PDataResponse>(
{
cacheKey,
cacheStrategy,
shouldCacheResult: (result: My3PDataResponse) => result.errors.length === 0,
},
async () => {
...
},
);

As an alternative to the withCache utility, you can also directly use the cache instance that's passed to the Storefront client and available in storefront.cache.

This cache instance follows the Cache API. Using the cache instance directly is a low-level approach and you need to handle all the cases and features manually, including error handling and stale-while-revalidate.

The following example shows how to cache a request to a third-party API with Oxygen:

Cache a sub-request to a third-party API using the cache instance

/app/routes/pages/example.jsx

const CMS_API_ENDPOINT = 'https://my-cms.com/api';

export async function loader({request, context: {storefront, waitUntil}}) {
const body = JSON.stringify(Object.fromEntries(new URL(request.url).searchParams.entries()));

// Create a new request based on a unique key representing the API request.
// This could use any unique URL that depends on the API request.
// For example, it could concatenate its text body or its sha256 hash.
const cacheUrl = new URL(CMS_API_ENDPOINT);
cacheUrl.pathname = '/cache' + cacheUrl.pathname + generateUniqueKeyFrom(body);
const cacheKey = new Request(cacheUrl.toString());

// Check if there's a match for this key.
let response = await storefront.cache.match(cacheKey);

if (!response) {
// Since there's no match, fetch a fresh response.
response = await fetch(CMS_API_ENDPOINT, {body, method: 'POST'});
// Make the response mutable.
response = new Response(response.body, response);
// Add caching headers to the response.
response.headers.set('Cache-Control', 'public, max-age=10')
// Store the response in cache to be re-used the next time.
waitUntil(storefront.cache.put(cacheKey, response.clone()));
}

return response;
}
import type {LoaderArgs} from '@shopify/remix-oxygen';

const CMS_API_ENDPOINT = 'https://my-cms.com/api';

export async function loader({request, context: {storefront, waitUntil}}: LoaderArgs) {
const body = JSON.stringify(Object.fromEntries(new URL(request.url).searchParams.entries()));

// Create a new request based on a unique key representing the API request.
// This could use any unique URL that depends on the API request.
// For example, it could concatenate its text body or its sha256 hash.
const cacheUrl = new URL(CMS_API_ENDPOINT);
cacheUrl.pathname = '/cache' + cacheUrl.pathname + generateUniqueKeyFrom(body);
const cacheKey = new Request(cacheUrl.toString());

// Check if there's a match for this key.
let response = await storefront.cache.match(cacheKey);

if (!response) {
// Since there's no match, fetch a fresh response.
response = await fetch(CMS_API_ENDPOINT, {body, method: 'POST'});
// Make the response mutable.
response = new Response(response.body, response);
// Add caching headers to the response.
response.headers.set('Cache-Control', 'public, max-age=10')
// Store the response in cache to be re-used the next time.
waitUntil(storefront.cache.put(cacheKey, response.clone()));
}

return response;
}

Was this page helpful?