Caching third-party API data with Hydrogen and Oxygen
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:
- Hydrogen’s built-in
withCache
utility (recommended) - Creating custom abstractions
- Caching content manually
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 with Cache 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
withCache.fetch
in Remix loaders and actionsAfter 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 () => {
...
},
);
Anchor to Manual cachingManual caching
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;
}