Data Fetching
When fetching data via the Recharge JavaScript SDK in Hydrogen we recommend always fetching data within a loader
/action
.
Make sure to set the 'Cache-Control': CACHE_NONE,
or 'Cache-Control': 'no-cache, no-store, must-revalidate',
header in your loader
/action
responses to prevent any caching between users as well.
Make sure you are committing the session (in case it changes) after calling the rechargeQueryWrapper
function in the response from your loader
/action
.
Subscription Card Component
Example component for display of subscriptions on Account page with link to a Subscription details page.
- JavaScript
- TypeScript
/app/components/SubscriptionCard.jsx
import { Money } from '@shopify/hydrogen';
import { Heading, Text, Link } from '~/components';
export function SubscriptionCard({ subscription, shopCurrency = 'USD' }) {
if (!subscription?.id) return null;
return (
<li className="grid text-center border rounded">
<Link
className="grid items-center gap-4 p-4 md:gap-6 md:p-6 md:grid-cols-2"
to={`/account/subscriptions/${subscription.id}`}
prefetch="intent"
>
<div className={`flex-col justify-center text-left md:col-span-2`}>
<Heading as="h3" format size="copy">
{`${subscription.product_title}${subscription.variant_title ? ` (${subscription.variant_title})` : ''}`}
</Heading>
<dl className="grid grid-gap-1">
<dt className="sr-only">Subscription ID</dt>
<dd>
<Text size="fine" color="subtle">
Subscription No. {subscription.id}
</Text>
</dd>
<dt className="sr-only">Subscription Date</dt>
<dd>
<Text size="fine" color="subtle">
{new Date(subscription.created_at).toDateString()}
</Text>
</dd>
<dt className="sr-only">Frequency</dt>
<dd className="mt-2">
<Text size="fine">{`${subscription.quantity} Every ${subscription.order_interval_frequency} ${subscription.order_interval_unit}(s)`}</Text>
</dd>
<dt className="sr-only">Price</dt>
<dd className="mt-2">
<Text size="fine">
<Money
data={{
amount: subscription.price,
currencyCode: subscription.presentment_currency ?? shopCurrency,
}}
/>
</Text>
</dd>
</dl>
</div>
</Link>
<div className="self-end border-t">
<Link
className="block w-full p-2 text-center"
to={`/account/subscriptions/${subscription.id}`}
prefetch="intent"
>
<Text color="subtle" className="ml-3">
View Details
</Text>
</Link>
</div>
</li>
);
}
/app/components/SubscriptionCard.tsx
import type { Subscription } from '@rechargeapps/storefront-client';
import { Money } from '@shopify/hydrogen';
import type { CurrencyCode } from '@shopify/hydrogen/storefront-api-types';
import { Heading, Text, Link } from '~/components';
export function SubscriptionCard({
subscription,
shopCurrency = 'USD',
}: {
subscription: Subscription;
shopCurrency?: CurrencyCode;
}) {
if (!subscription?.id) return null;
return (
<li className="grid text-center border rounded">
<Link
className="grid items-center gap-4 p-4 md:gap-6 md:p-6 md:grid-cols-2"
to={`/account/subscriptions/${subscription.id}`}
prefetch="intent"
>
<div className={`flex-col justify-center text-left md:col-span-2`}>
<Heading as="h3" format size="copy">
{`${subscription.product_title}${subscription.variant_title ? ` (${subscription.variant_title})` : ''}`}
</Heading>
<dl className="grid grid-gap-1">
<dt className="sr-only">Subscription ID</dt>
<dd>
<Text size="fine" color="subtle">
Subscription No. {subscription.id}
</Text>
</dd>
<dt className="sr-only">Subscription Date</dt>
<dd>
<Text size="fine" color="subtle">
{new Date(subscription.created_at).toDateString()}
</Text>
</dd>
<dt className="sr-only">Frequency</dt>
<dd className="mt-2">
<Text size="fine">{`${subscription.quantity} Every ${subscription.order_interval_frequency} ${subscription.order_interval_unit}(s)`}</Text>
</dd>
<dt className="sr-only">Price</dt>
<dd className="mt-2">
<Text size="fine">
<Money
data={{
amount: subscription.price,
currencyCode: (subscription.presentment_currency ?? shopCurrency) as CurrencyCode,
}}
/>
</Text>
</dd>
</dl>
</div>
</Link>
<div className="self-end border-t">
<Link
className="block w-full p-2 text-center"
to={`/account/subscriptions/${subscription.id}`}
prefetch="intent"
>
<Text color="subtle" className="ml-3">
View Details
</Text>
</Link>
</div>
</li>
);
}
Fetch Subscriptions and Render Subscription Component
Fetch subscriptions for a customer after they have logged in.
- JavaScript
- TypeScript
/app/routes/($locale).account.jsx
import { listSubscriptions } from '@rechargeapps/storefront-client';
import { SubscriptionCard } from '~/components/SubscriptionCard';
import { rechargeQueryWrapper } from '~/lib/rechargeUtils';
// inside the loader function
const subscriptionsResponse = await rechargeQueryWrapper(
session =>
listSubscriptions(session, {
limit: 25,
status: 'active',
}),
{
hydrogenSession: context.session,
shopifyStorefrontToken: context.env.PUBLIC_STOREFRONT_API_TOKEN,
}
);
// make sure you return subscriptionResponse from the loader and commit the session changes
return defer(
{
isAuthenticated,
customer,
heading,
subscriptionsResponse,
featuredData: getFeaturedData(context.storefront),
},
{
headers: {
'Cache-Control': CACHE_NONE,
'Set-Cookie': await context.session.commit(),
},
}
);
// add subscriptionResponse to the Account props and add AccountSubscriptions rendering to the Account Component
function Account({ customer, heading, featuredData, subscriptionsResponse }) {
const orders = flattenConnection(customer.orders);
const addresses = flattenConnection(customer.addresses);
return (
<>
<PageHeader heading={heading}>
<Form method="post" action={usePrefixPathWithLocale('/account/logout')}>
<button type="submit" className="text-primary/50">
Sign out
</button>
</Form>
</PageHeader>
{subscriptionsResponse.subscriptions && (
<AccountSubscriptions subscriptions={subscriptionsResponse.subscriptions} />
)}
{orders && <AccountOrderHistory orders={orders} />}
<AccountDetails customer={customer} />
<AccountAddressBook addresses={addresses} customer={customer} />
{!orders.length && (
<Suspense>
<Await resolve={featuredData} errorElement="There was a problem loading featured products.">
{data => (
<>
<FeaturedCollections title="Popular Collections" collections={data.featuredCollections} />
<ProductSwimlane products={data.featuredProducts} />
</>
)}
</Await>
</Suspense>
)}
</>
);
}
// Add additional needed local components
function AccountSubscriptions({ subscriptions }) {
return (
<div className="mt-6">
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h2 className="font-bold text-lead">Active Subscriptions</h2>
{subscriptions?.length ? <Subscriptions subscriptions={subscriptions} /> : <EmptySubscriptions />}
</div>
</div>
);
}
function Subscriptions({ subscriptions }) {
return (
<ul className="grid grid-flow-row grid-cols-1 gap-2 gap-y-6 md:gap-4 lg:gap-6 false sm:grid-cols-3">
{subscriptions.map(subscription => (
<SubscriptionCard subscription={subscription} key={subscription.id} />
))}
</ul>
);
}
function EmptySubscriptions() {
return (
<div>
<Text className="mb-1" size="fine" width="narrow" as="p">
You don't have any active subscriptions.
</Text>
<div className="w-48">
<Button className="w-full mt-2 text-sm" variant="secondary" to={usePrefixPathWithLocale('/')}>
Start Shopping
</Button>
</div>
</div>
);
}
/app/routes/($locale).account.tsx
import type { Subscription, SubscriptionsResponse } from '@rechargeapps/storefront-client';
import { listSubscriptions } from '@rechargeapps/storefront-client';
import { SubscriptionCard } from '~/components/SubscriptionCard';
import { rechargeQueryWrapper } from '~/lib/rechargeUtils';
// inside the loader function
const subscriptionsResponse = await rechargeQueryWrapper(
session =>
listSubscriptions(session, {
limit: 25,
status: 'active',
}),
{
hydrogenSession: context.session,
shopifyStorefrontToken: context.env.PUBLIC_STOREFRONT_API_TOKEN,
}
);
// make sure you return subscriptionResponse from the loader and commit the session changes
return defer(
{
isAuthenticated,
customer,
heading,
subscriptionsResponse,
featuredData: getFeaturedData(context.storefront),
},
{
headers: {
'Cache-Control': CACHE_NONE,
'Set-Cookie': await context.session.commit(),
},
}
);
// add subscriptionResponse to the AccountType interface
interface AccountType {
customer: CustomerDetailsFragment;
featuredData: Promise<FeaturedData>;
heading: string;
subscriptionsResponse: SubscriptionsResponse;
}
// add AccountSubscriptions rendering to the Account Component
function Account({ customer, heading, featuredData, subscriptionsResponse }: AccountType) {
const orders = flattenConnection(customer.orders);
const addresses = flattenConnection(customer.addresses);
return (
<>
<PageHeader heading={heading}>
<Form method="post" action={usePrefixPathWithLocale('/account/logout')}>
<button type="submit" className="text-primary/50">
Sign out
</button>
</Form>
</PageHeader>
{subscriptionsResponse.subscriptions && (
<AccountSubscriptions subscriptions={subscriptionsResponse.subscriptions} />
)}
{orders && <AccountOrderHistory orders={orders} />}
<AccountDetails customer={customer} />
<AccountAddressBook addresses={addresses} customer={customer} />
{!orders.length && (
<Suspense>
<Await resolve={featuredData} errorElement="There was a problem loading featured products.">
{data => (
<>
<FeaturedCollections title="Popular Collections" collections={data.featuredCollections} />
<ProductSwimlane products={data.featuredProducts} />
</>
)}
</Await>
</Suspense>
)}
</>
);
}
// AccountSubscriptions component
function AccountSubscriptions({ subscriptions }: { subscriptions: Subscription[] }) {
return (
<div className="mt-6">
<div className="grid w-full gap-4 p-4 py-6 md:gap-8 md:p-8 lg:p-12">
<h2 className="font-bold text-lead">Active Subscriptions</h2>
{subscriptions?.length ? <Subscriptions subscriptions={subscriptions} /> : <EmptySubscriptions />}
</div>
</div>
);
}
function Subscriptions({ subscriptions }: { subscriptions: Subscription[] }) {
return (
<ul className="grid grid-flow-row grid-cols-1 gap-2 gap-y-6 md:gap-4 lg:gap-6 false sm:grid-cols-3">
{subscriptions.map(subscription => (
<SubscriptionCard subscription={subscription} key={subscription.id} />
))}
</ul>
);
}
function EmptySubscriptions() {
return (
<div>
<Text className="mb-1" size="fine" width="narrow" as="p">
You don't have any active subscriptions.
</Text>
<div className="w-48">
<Button className="w-full mt-2 text-sm" variant="secondary" to={usePrefixPathWithLocale('/')}>
Start Shopping
</Button>
</div>
</div>
);
}
Subscription Details Route & Page
Example subscriptions detail page that renders Subscription Details with a link to Active Churn Recovery landing page flow.
- JavaScript
- TypeScript
/app/routes/($locale).subscriptions.$id.jsx
import clsx from 'clsx';
import { json, redirect } from '@shopify/remix-oxygen';
import { useLoaderData } from '@remix-run/react';
import { Money } from '@shopify/hydrogen';
import { getActiveChurnLandingPageURL, getSubscription } from '@rechargeapps/storefront-client';
import { Link, Heading, PageHeader, Text, Button } from '~/components';
import { rechargeQueryWrapper } from '~/lib/rechargeUtils';
import { CACHE_NONE } from '~/data/cache';
export const meta = ({ data }) => {
return [
{
title: `Subscription ${data?.subscription?.product_title}${
data?.subscription?.variant_title ? ` (${data?.subscription?.variant_title})` : ''
}`,
},
];
};
export async function loader({ request, context, params }) {
if (!params.id) {
return redirect(params?.locale ? `${params.locale}/account` : '/account');
}
const customerAccessToken = await context.session.get('customerAccessToken');
if (!customerAccessToken) {
return redirect(params.locale ? `${params.locale}/account/login` : '/account/login');
}
const subscription = await rechargeQueryWrapper(
session =>
getSubscription(session, params.id, {
include: ['address'],
}),
{
hydrogenSession: context.session,
shopifyStorefrontToken: context.env.PUBLIC_STOREFRONT_API_TOKEN,
}
);
if (!subscription) {
throw new Response('Subscription not found', { status: 404 });
}
const cancelUrl = await rechargeQueryWrapper(
session => getActiveChurnLandingPageURL(session, params.id, request.url),
{
hydrogenSession: context.session,
shopifyStorefrontToken: context.env.PUBLIC_STOREFRONT_API_TOKEN,
}
);
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: {
id: `gid://shopify/Product/${subscription.external_variant_id.ecommerce}`,
},
});
return json(
{
subscription,
product,
cancelUrl,
shopCurrency: 'USD',
},
{
headers: {
'Cache-Control': CACHE_NONE,
'Set-Cookie': await context.session.commit(),
},
}
);
}
export default function SubscriptionRoute() {
const { subscription, product, cancelUrl, shopCurrency } = useLoaderData();
const address = subscription.include?.address;
return (
<div>
<PageHeader heading="Subscription detail">
<Link to="/account">
<Text color="subtle">Return to Account Overview</Text>
</Link>
</PageHeader>
<div className="w-full p-6 sm:grid-cols-1 md:p-8 lg:p-12 lg:py-6">
<div>
<Text as="h3" size="lead">
Subscription No. {subscription.id}
</Text>
<Text className="mt-2" as="p">
Placed on {new Date(subscription.created_at).toDateString()}
</Text>
<div className="grid items-start gap-12 sm:grid-cols-1 md:grid-cols-4 md:gap-16 sm:divide-y sm:divide-gray-200">
<table className="min-w-full my-8 divide-y divide-gray-300 md:col-span-3">
<thead>
<tr className="align-baseline ">
<th scope="col" className="pb-4 pl-0 pr-3 font-semibold text-left">
Product
</th>
<th scope="col" className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell">
Price
</th>
<th scope="col" className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell">
Quantity
</th>
<th scope="col" className="px-4 pb-4 font-semibold text-right">
Frequency
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="w-full py-4 pl-0 pr-3 align-top sm:align-middle max-w-0 sm:w-auto sm:max-w-none">
<div className="flex gap-6">
<Link to={`/products/${product?.handle}`}>View Product</Link>
<div className="flex-col justify-center hidden lg:flex">
<Text as="p">{subscription.product_title}</Text>
{subscription.variant_title && (
<Text size="fine" className="mt-1" as="p">
{subscription.variant_title}
</Text>
)}
</div>
<dl className="grid">
<dt className="sr-only">Product</dt>
<dd className="truncate lg:hidden">
<Heading size="copy" format as="h3">
{subscription.product_title}
</Heading>
{subscription.variant_title && (
<Text size="fine" className="mt-1">
{subscription.variant_title}
</Text>
)}
</dd>
<dt className="sr-only">Price</dt>
<dd className="truncate sm:hidden">
<Text size="fine" className="mt-4">
<Money
data={{
amount: subscription.price,
currencyCode: subscription.presentment_currency ?? shopCurrency,
}}
/>
</Text>
</dd>
<dt className="sr-only">Quantity</dt>
<dd className="truncate sm:hidden">
<Text className="mt-1" size="fine">
Qty: {subscription.quantity}
</Text>
</dd>
</dl>
</div>
</td>
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
<Money
data={{
amount: subscription.price,
currencyCode: subscription.presentment_currency ?? shopCurrency,
}}
/>
</td>
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
{subscription.quantity}
</td>
<td className="px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
<Text>
{`Every ${subscription.order_interval_frequency} ${subscription.order_interval_unit}(s)`}
</Text>
</td>
</tr>
</tbody>
</table>
<div className="sticky border-none top-nav md:my-8">
<Heading size="copy" className="font-semibold" as="h3">
Address
</Heading>
{address ? (
<ul className="mt-6">
<li>
<Text>
{address.first_name && address.first_name + ' '}
{address.last_name}
</Text>
</li>
{address ? (
<>
<li>
<Text>{address.address1}</Text>
</li>
{address.address2 && (
<li>
<Text>{address.address2}</Text>
</li>
)}
<li>
<Text>
{address.city} {address.province} {address.zip} {address.country_code}
</Text>
</li>
</>
) : (
<></>
)}
</ul>
) : (
<p className="mt-3">No address defined</p>
)}
<Heading size="copy" className="mt-8 font-semibold" as="h3">
Status
</Heading>
<div
className={clsx(
`mt-3 px-3 py-1 text-xs font-medium rounded-full inline-block w-auto`,
subscription.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-primary/20 text-primary/50'
)}
>
<Text size="fine" className="uppercase">
{subscription.status}
</Text>
</div>
</div>
</div>
</div>
{subscription.status === 'active' && (
<Button as={Link} variant="secondary" to={cancelUrl}>
Cancel Subscription
</Button>
)}
</div>
</div>
);
}
const PRODUCT_QUERY = `#graphql
query getProductById($id: ID!) {
product(id: $id) {
id
handle
}
}
`;
/app/routes/($locale).subscriptions.$id.tsx
import clsx from 'clsx';
import { json, redirect, type LoaderFunctionArgs } from '@shopify/remix-oxygen';
import { useLoaderData, type MetaFunction } from '@remix-run/react';
import { Money } from '@shopify/hydrogen';
import { getActiveChurnLandingPageURL, getSubscription } from '@rechargeapps/storefront-client';
import type { CurrencyCode } from '@shopify/hydrogen/storefront-api-types';
import { Link, Heading, PageHeader, Text, Button } from '~/components';
import { rechargeQueryWrapper } from '~/lib/rechargeUtils';
import { CACHE_NONE } from '~/data/cache';
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{
title: `Subscription ${data?.subscription?.product_title}${
data?.subscription?.variant_title ? ` (${data?.subscription?.variant_title})` : ''
}`,
},
];
};
export async function loader({ request, context, params }: LoaderFunctionArgs) {
if (!params.id) {
return redirect(params?.locale ? `${params.locale}/account` : '/account');
}
const customerAccessToken = await context.session.get('customerAccessToken');
if (!customerAccessToken) {
return redirect(params.locale ? `${params.locale}/account/login` : '/account/login');
}
const subscription = await rechargeQueryWrapper(
session =>
getSubscription(session, params.id!, {
include: ['address'],
}),
{
hydrogenSession: context.session,
shopifyStorefrontToken: context.env.PUBLIC_STOREFRONT_API_TOKEN,
}
);
if (!subscription) {
throw new Response('Subscription not found', { status: 404 });
}
const cancelUrl = await rechargeQueryWrapper(
session => getActiveChurnLandingPageURL(session, params.id!, request.url),
{
hydrogenSession: context.session,
shopifyStorefrontToken: context.env.PUBLIC_STOREFRONT_API_TOKEN,
}
);
const { product } = await context.storefront.query(PRODUCT_QUERY, {
variables: {
id: `gid://shopify/Product/${subscription.external_variant_id.ecommerce!}`,
},
});
return json(
{
subscription,
product,
cancelUrl,
shopCurrency: 'USD',
},
{
headers: {
'Cache-Control': CACHE_NONE,
'Set-Cookie': await context.session.commit(),
},
}
);
}
export default function SubscriptionRoute() {
const { subscription, product, cancelUrl, shopCurrency } = useLoaderData<typeof loader>();
const address = subscription.include?.address;
return (
<div>
<PageHeader heading="Subscription detail">
<Link to="/account">
<Text color="subtle">Return to Account Overview</Text>
</Link>
</PageHeader>
<div className="w-full p-6 sm:grid-cols-1 md:p-8 lg:p-12 lg:py-6">
<div>
<Text as="h3" size="lead">
Subscription No. {subscription.id}
</Text>
<Text className="mt-2" as="p">
Placed on {new Date(subscription.created_at!).toDateString()}
</Text>
<div className="grid items-start gap-12 sm:grid-cols-1 md:grid-cols-4 md:gap-16 sm:divide-y sm:divide-gray-200">
<table className="min-w-full my-8 divide-y divide-gray-300 md:col-span-3">
<thead>
<tr className="align-baseline ">
<th scope="col" className="pb-4 pl-0 pr-3 font-semibold text-left">
Product
</th>
<th scope="col" className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell">
Price
</th>
<th scope="col" className="hidden px-4 pb-4 font-semibold text-right sm:table-cell md:table-cell">
Quantity
</th>
<th scope="col" className="px-4 pb-4 font-semibold text-right">
Frequency
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
<tr>
<td className="w-full py-4 pl-0 pr-3 align-top sm:align-middle max-w-0 sm:w-auto sm:max-w-none">
<div className="flex gap-6">
<Link to={`/products/${product?.handle}`}>View Product</Link>
<div className="flex-col justify-center hidden lg:flex">
<Text as="p">{subscription.product_title}</Text>
{subscription.variant_title && (
<Text size="fine" className="mt-1" as="p">
{subscription.variant_title}
</Text>
)}
</div>
<dl className="grid">
<dt className="sr-only">Product</dt>
<dd className="truncate lg:hidden">
<Heading size="copy" format as="h3">
{subscription.product_title}
</Heading>
{subscription.variant_title && (
<Text size="fine" className="mt-1">
{subscription.variant_title}
</Text>
)}
</dd>
<dt className="sr-only">Price</dt>
<dd className="truncate sm:hidden">
<Text size="fine" className="mt-4">
<Money
data={{
amount: subscription.price,
currencyCode: (subscription.presentment_currency ?? shopCurrency) as CurrencyCode,
}}
/>
</Text>
</dd>
<dt className="sr-only">Quantity</dt>
<dd className="truncate sm:hidden">
<Text className="mt-1" size="fine">
Qty: {subscription.quantity}
</Text>
</dd>
</dl>
</div>
</td>
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
<Money
data={{
amount: subscription.price,
currencyCode: (subscription.presentment_currency ?? shopCurrency) as CurrencyCode,
}}
/>
</td>
<td className="hidden px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
{subscription.quantity}
</td>
<td className="px-3 py-4 text-right align-top sm:align-middle sm:table-cell">
<Text>
{`Every ${subscription.order_interval_frequency} ${subscription.order_interval_unit}(s)`}
</Text>
</td>
</tr>
</tbody>
</table>
<div className="sticky border-none top-nav md:my-8">
<Heading size="copy" className="font-semibold" as="h3">
Address
</Heading>
{address ? (
<ul className="mt-6">
<li>
<Text>
{address.first_name && address.first_name + ' '}
{address.last_name}
</Text>
</li>
{address ? (
<>
<li>
<Text>{address.address1}</Text>
</li>
{address.address2 && (
<li>
<Text>{address.address2}</Text>
</li>
)}
<li>
<Text>
{address.city} {address.province} {address.zip} {address.country_code}
</Text>
</li>
</>
) : (
<></>
)}
</ul>
) : (
<p className="mt-3">No address defined</p>
)}
<Heading size="copy" className="mt-8 font-semibold" as="h3">
Status
</Heading>
<div
className={clsx(
`mt-3 px-3 py-1 text-xs font-medium rounded-full inline-block w-auto`,
subscription.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-primary/20 text-primary/50'
)}
>
<Text size="fine" className="uppercase">
{subscription.status}
</Text>
</div>
</div>
</div>
</div>
{subscription.status === 'active' && (
<Button as={Link} variant="secondary" to={cancelUrl}>
Cancel Subscription
</Button>
)}
</div>
</div>
);
}
const PRODUCT_QUERY = `#graphql
query getProductById($id: ID!) {
product(id: $id) {
id
handle
}
}
` as const;