How to Build a Custom Checkout for Digital Products Using Nimara Storefront?
Nimara essentially gives you a front end that’s ready to go but also highly customizable. In other words, you don’t have to reinvent the wheel, yet you can still adjust the checkout to suit different scenarios (like our digital products example).
Have you ever had that moment when your client says, “By the way, we’re adding digital products, so no shipping is needed,” and you just know the checkout flow is about to get weird?
One minute, you’re handling physical goods (quite standard), and the next, you’re wrangling shipping forms for items that live in the cloud.
Customers end up scratching their heads over why they have to enter a shipping address for a file they’ll download or stream.
You start hacking conditionals into your code; hiding shipping steps, jumping around the standard flow… and pretty soon the checkout feels like a maze.
We’ve all been there.
Nimara Storefront Has Your Back
Instead of forcing you to rip out shipping logic by the roots, Saleor gives you a neat switch - isShippingRequired
- so you can elegantly adapt your checkout for digital items.
Below is a step-by-step tutorial that shows exactly how to update Nimara’s checkout flow to skip shipping for digital items.
Add the isShippingRequired Field to the Checkout Fragment
Begin by telling your front end whether shipping is needed. Modify your checkout GraphQL fragment by adding the isShippingRequired
field like so:
diff
--- a/packages/infrastructure/src/public/saleor/checkout/graphql/fragments/CheckoutFragment.graphql
+++ b/packages/infrastructure/src/public/saleor/checkout/graphql/fragments/CheckoutFragment.graphql
@@ -52,6 +52,7 @@ fragment CheckoutFragment on Checkout {
shippingPrice {
...TaxedMoneyFragment
}
+ isShippingRequired
authorizeStatus
chargeStatus
problems {
Why It Matters?
Without this field, your front end has no clue that some items don’t require shipping. Since Saleor can differentiate between physical and digital items, that field will tell Nimara whether or not to show the shipping steps.
Generate the Updated Types
Anytime you modify a GraphQL fragment, you must regenerate your TypeScript types:
codegen
$ pnpm codegen
This keeps your codebase typesafe, so you can rely on the fact that the isShippingRequired
is recognized everywhere.
Extend the Checkout Domain Object
Next, open up your checkout domain object. Since your GraphQL fragment now has a new field, your domain object should reflect that:
diff
--- a/packages/domain/src/objects/Checkout.ts
+++ b/packages/domain/src/objects/Checkout.ts
@@ -11,6 +11,7 @@ export type Checkout = {
displayGrossPrices: boolean;
email: string | null;
id: string;
+ isShippingRequired: boolean;
lines: Line[];
problems: {
insufficientStock: CheckoutLineProblemInsufficientStock[];
Why Is It Needed?
Your domain layer is critical—it’s where your application’s business logic comes together.
This step assures your domain layer aligns perfectly with the data coming from Saleor. It’s a small but crucial detail in maintaining a clean architecture.
Serialize isShippingRequired in Nimara’s Infrastructure
Before the front-end can properly use isShippingRequired
, make sure to serialize it in the Nimara infrastructure.
diff
--- a/packages/infrastructure/src/public/saleor/checkout/infrastructure/checkout-get-infra.ts
+++ b/packages/infrastructure/src/public/saleor/checkout/infrastructure/checkout-get-infra.ts
@@ -41,6 +41,7 @@ const serializeCheckout = (checkout: CheckoutFragment): Checkout => {
return {
...checkout,
+ isShippingRequired: !!checkout.isShippingRequired,
problems: {
insufficientStock:
checkout.problems
Why It Helps?
We’re mapping the GraphQL data into our Checkout type.
The double-bang !!
operator is a neat way of converting whatever value isShippingRequired
into a strict boolean value:true
or false
.
So downstream components don’t have to guess or handle nullish values.
Customize the Checkout Flow for Digital Products
Now comes the fun part: letting Nimara know that the user shouldn’t be forced through shipping screens as it’s unnecessary in this case. We’ll start on the main checkout page:
diff
--- a/apps/storefront/src/app/[locale]/(checkout)/checkout/page.tsx
+++ b/apps/storefront/src/app/[locale]/(checkout)/checkout/page.tsx
@@ -38,12 +38,14 @@ export default async function Page() {
redirect({ href: paths.checkout.userDetails.asPath(), locale });
}
- if (user?.email || !checkout.shippingAddress) {
- redirect({ href: paths.checkout.shippingAddress.asPath(), locale });
- }
-
- if (!checkout.deliveryMethod) {
- redirect({ href: paths.checkout.deliveryMethod.asPath(), locale });
+ if (checkout.isShippingRequired) {
+ if (user?.email || !checkout.shippingAddress) {
+ redirect({ href: paths.checkout.shippingAddress.asPath(), locale });
+ }
+
+ if (!checkout.deliveryMethod) {
+ redirect({ href: paths.checkout.deliveryMethod.asPath(), locale });
+ }
}
What’s Happening Here?
- We check
checkout.isShippingRequired
to see if we need to redirect the shipping address and delivery method steps. - If it’s not required (i.e., a digital product), we skip those steps altogether, and the users go to the next relevant step.
Redirect from Shipping-Related Pages When Unneeded
If a user manually navigates to the shipping or delivery method pages for a digital-only checkout, we’ll politely send them back to the main checkout page.
It's a good user experience - there is no sense in letting them wonder into a shipping page if they don't need it.
diff
--- a/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/delivery-method/page.tsx
+++ b/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/delivery-method/page.tsx
@@ -36,6 +36,10 @@ export default async function Page() {
redirect({ href: paths.cart.asPath(), locale });
}
+ if (!checkout.isShippingRequired) {
+ redirect({ href: paths.checkout.asPath(), locale });
+ }
+
if (checkout.problems.insufficientStock.length) {
return <CheckoutSkeleton />;
}
diff
--- a/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/shipping-address/page.tsx
+++ b/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/shipping-address/page.tsx
@@ -45,6 +45,10 @@ export default async function Page(props: { searchParams: SearchParams }) {
redirect({ href: paths.cart.asPath(), locale });
}
+ if (!checkout.isShippingRequired) {
+ redirect({ href: paths.checkout.asPath(), locale });
+ }
+
if (checkout.problems.insufficientStock.length) {
return <CheckoutSkeleton />;
}
Why Is It Important?
Keeping users on the correct path means reducing confusion and fewer errors. It’s a small detail that contributes to a smoother overall experience.
Send Users Directly to Payment When Shipping Isn’t Required
Now, let's update the updateUserDetails
action to handle what happens after users update their details.
By default, Nimara sends them to the shipping address step, but if shipping isn't required, they should go straight to payment:
diff
--- a/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/user-details/_forms/actions.ts
+++ b/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/user-details/_forms/actions.ts
@@ -60,5 +60,9 @@ export const updateUserDetails = async ({
};
}
- return { redirectUrl: paths.checkout.shippingAddress.asPath() };
+ return {
+ redirectUrl: checkout.isShippingRequired
+ ? paths.checkout.shippingAddress.asPath()
+ : paths.checkout.payment.asPath(),
+ };
};
Adjust the Payment Step for Digital Products
Finally, let’s hide any irrelevant shipping sections, such as the shipping address form: ShippingAddressSection
and the delivery method selector: DeliveryMethodSection
.
diff
--- a/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/payment/page.tsx
+++ b/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/payment/page.tsx
@@ -113,8 +113,12 @@ export default async function Page(props: { searchParams: SearchParams }) {
return (
<>
<EmailSection checkout={checkout} user={user} />
- <ShippingAddressSection checkout={checkout} />
- <DeliveryMethodSection checkout={checkout} />
+ {checkout.isShippingRequired && (
+ <>
+ <ShippingAddressSection checkout={checkout} />
+ <DeliveryMethodSection checkout={checkout} />
+ </>
+ )}
<PaymentSection>
<Payment
paymentGatewayCustomer={paymentGatewayCustomer}
Keep the Billing Section on the Payment Page
Some forms, like billing address, are still crucial for taxes and compliance (especially if you’re selling internationally). Hide or show relevant sections in payment/page.tsx:
diff
--- a/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/payment/payment.tsx
+++ b/apps/storefront/src/app/[locale]/(checkout)/checkout/(checkout-details)/payment/payment.tsx
@@ -126,7 +126,9 @@ export const Payment = ({
billingAddress: hasDefaultBillingAddress
? formattedToSchemaDefaultBillingAddress
: defaultEmptyBillingAddress,
- sameAsShippingAddress: !hasDefaultBillingAddressInCurrentChannel,
+ sameAsShippingAddress: checkout.isShippingRequired
+ ? !hasDefaultBillingAddressInCurrentChannel
+ : false,
saveAddressForFutureUse,
saveForFutureUse: !!user,
paymentMethod:
@@ -405,12 +407,14 @@ export const Payment = ({
{t("payment.billingAddress")}
</h3>
- <div className="flex w-full items-center gap-2 rounded-md border border-input bg-background px-4">
- <CheckboxField
- label={t("payment.same-as-shipping-address")}
- name="sameAsShippingAddress"
- />
- </div>
+ {checkout.isShippingRequired && (
+ <div className="flex w-full items-center gap-2 rounded-md border border-input bg-background px-4">
+ <CheckboxField
+ label={t("payment.same-as-shipping-address")}
+ name="sameAsShippingAddress"
+ />
+ </div>
+ )}
{user ? (
<>
Pro Tip
It’s good to mention that digital goods will be delivered electronically, especially if your customers are used to shipping for all purchases.
Where to include such information on the checkout page - is up to you. Just make sure it’s hard to miss so buyers know exactly how they’ll receive their digital products.
That's it! Now Your Checkout Will Support Digital Products
If you’ve followed all these steps, you should now have a working checkout that streamlines the process for non-shippable products.
The changes revolve around a single property isShippingRequired
that influences multiple screens in your app, guiding customers to the right place.
There’s always room for more customization, but hopefully, this guide gives you a solid, real-world example of extending Nimara for a digital-first shopping experience.
If you have any questions or want to share your own checkout customizations, feel free to reach out in the Nimara community. We’d love to see what you build next!
Have Questions or Ideas?
Drop us a message on Discord - we can’t wait to hear from you!