Crystallize logo

Setting Up Subscription eCommerce with Crystallize

Setting up a subscription commerce system with Crystallize, Next.js, and Typescript offers flexibility and control over subscription logic while efficiently managing recurring billing and contract management through scalable architecture.

clockPublished February 25, 2025
clock15 minutes
Sébastien Morel
Sébastien Morel
Setting Up Subscription eCommerce with Crystallize

Before we start, since this article will cover an entire Subscription System, I recommend revisiting and reading the Subscription API Overview blog post to understand the different concepts. Subscription documentation and Subscription Commerce Masterclass are excellent sources of knowledge and can help you overcome any hiccups you might encounter with this article.

Finally, you might want to juggle your memory and revisit the subscription business models post for business insight and the bigger picture.

What Are We Going to Build?

In this article, we will build a NERDY FACTORY DEMO, which can be used as an Accelerator to help you get started with Subscriptions and Crystallize.

Building a subscription commerce project is a common yet essential use case where users can seamlessly purchase and manage their subscriptions. In this setup, we have two main products:

  • Super App
  • My Streaming Service

Each product offers three subscription plans, defined as SKUs within Crystallize:

  • Super App
    • Flat
    • Volume
    • Graduated
  • My Streaming Service
    • Basic
    • Premium
    • Family

Core Requirements

To ensure a smooth subscription flow, we must cover both the customer experience and the backend processes that drive the system.

Customer Journey

  • As a visitor, I must be able to log in or register to purchase a subscription.
  • As a buyer, I must provide my billing address during checkout.
  • After subscribing, I must see a thank you page confirming my purchase.
  • As a buyer, I can access my active subscriptions and order history from my account page.

Backend Process

  • When a user registers, a Customer record must be created in Crystallize.
  • When a subscription is purchased, an Order must be generated.
  • On each renewal date, a new Order must be created to process the recurring payment.

Advanced Requirements

  • As an Admin, I need a way to handle failed payments and trigger a recapture process.
  • Orders should have a state management system to track success, retries, or failures.

For simplicity, we will keep only one currency and one language.

Content Modeling and Key Concepts for Subscription Commerce

Based on those requirements, the next step is usually Content modeling. We will keep it abstract here. Here is the minimum to know to implement the subscription flow.

  • A Tenant can have one or more Subscription Plans
  • Plans can include metered variables (optional for usage-based pricing)
  • A Tenant has one or more Product Shapes
  • A Product Item contains one or more SKUs
  • Pricing is assigned at the SKU level, meaning Subscription Plan Pricing is per SKU & Plan
  • An Order is a frozen-in-time record of transactions.
  • A Subscription Contract is an agreement that ensures a future order at a predetermined price.

With this foundation in place, the next step is implementing the subscription flow, handling orders, renewals, and payments efficiently within Crystallize.

The Flow

Crystallize is non-opinionated, meaning you can build the flow however you desire. This one is (kind of) a tried-and-tested version or the recommended flow.

This does not show anything about authentication, which we will also cover briefly. Regarding the architecture and framework, we will keep it simple here and use Next.js 15. We mix the front and back end (Service API) using Next.js's server-side capabilities.

Now that we’re all set up… let’s start coding!

Authentication

Authentication is a key part of a simple subscription flow, as users should be able to log in or register before purchasing a subscription. The approach taken in this Accelerator is a simple email-based authentication using Magic Links. Please note that once again, you’re not locked in that mechanism, the subscription could be bought by one buyer and shared with another. Crystallize is non-opinionated, and you can use many different flows.

Let’s break down the authentication system. The goal is simple: users must log in or register before buying a subscription.

1. Login & Registration

  • The login form only asks for an email—no passwords, no hassle.
  • When the user submits their email, a Magic Link is generated.
  • For demo purposes, we just display the link in the UI, but in production, this would be sent via an email service (Resend, Postmark, AWS SES, etc.).
  • Clicking the Magic Link will authenticate the user automatically.

2. Magic Link Verification

  • The system extracts the email from the token when the user clicks the link
  • If the token is valid:
    • We create a customer record in Crystallize (if it doesn’t already exist)
    • A session token is stored in a cookie, so the user stays logged in.

3. Protecting Routes (Middleware)

  • Some pages, like “My Subscriptions,” should only be accessible to logged-in users.
  • This is handled by Next.js Middleware, which checks for the session token and redirects to the login page if it is missing.

Key Implementation

Start with the following:

export default function LoginOrRegisterForm({ redirect }: { redirect?: string }) {
   const [link, action, pending] = useActionState(sendMagickLinkAction, null);
   const [currentForm, setCurrentForm] = useState<'login' | 'register'>('login');
   return (
       <div className="block w-full">
           {link ? (
               <div className=" bg-yellow/30 p-12 rounded-xl border border-yellow">
                   <h2 className="text-black font-bold text-xl">Abracadabra!</h2>
                   <p className="text-lg">A magic link has appeared in your inbox—poof! ✨ Click it to log in 🚀</p>
                   <p className="border-t border-black/10 mt-4 pt-4">
                       Still no sign of it? Check your spam folder or click{' '}
                       <a className="text-black font-bold underline" href={link}>
                           here
                       </a>{' '}
                       to cheat the system.
                   </p>
               </div>
           ) : (
               <>
                   <div className="flex gap-2 py-2">
                       <button
                           className={clsx('font-medium text-black text-black/60', {
                               '!font-bold !text-black underline': currentForm === 'login',
                           })}
                           onClick={() => setCurrentForm('login')}
                       >
                           Login
                       </button>
                       <span className="text-black/60">/</span>
                       <button
                           className={clsx('font-medium text-black text-black/60', {
                               '!font-bold !text-black underline': currentForm === 'register',
                           })}
                           onClick={() => setCurrentForm('register')}
                       >
                           Create account
                       </button>
                   </div>
                   {currentForm === 'login' ? (
                       <Login redirect={redirect} action={action} pending={pending} />
                   ) : (
                       <CreateAccount redirect={redirect} action={action} pending={pending} />
                   )}
               </>
           )}
       </div>
   );
}

What matters here is that we are using a Server Action that will return a link.

const [link, action, pending] = useActionState(sendMagickLinkAction, null);

Let’s have a look at the Action:

export async function sendMagickLinkAction(prevState: unknown, formData: FormData) {
   const headersList = await headers();
   const host = headersList.get('host');
   const email = formData.get('email') as string;
   const redirect = (formData.get('redirect') as string) || '/';
   const token = await authenticator.createToken(email);
   if (host?.includes('localhost')) {
       return `http://${host}/magick-link/${token}?redirect=${redirect}`;
   }
   return `https://${host}/magick-link/${token}?redirect=${redirect}`;
}

This is pretty standard code. Let’s not dive into the createToken function; just know it will generate and sign a JWT Token.

When the user clicks the Magic Link, a Next.js route is triggered. This route decodes the token and verifies its authenticity.

If the token is valid:

  • It is stored in a cookie so the middleware can use it for authentication in subsequent requests.
  • A Customer is created in Crystallize if they do not already exist.

In a production setup, it is recommended that a new authentication token be generated upon verification. This ensures that the token sent via email cannot be directly reused for authentication. While this is not a concern for this demonstration, it is an important security best practice.

Here is the code of the route:

export async function GET(request: Request, { params }: { params: Promise<{ token: string }> }) {
   const url = new URL(request.url);
   const redirectTo = url.searchParams.get('redirect') || '/';
   const token = (await params).token;
   try {
       const payload = await authenticator.decodeToken(token);
       const cookieStore = await cookies();
       cookieStore.set('auth.token', token, {
           httpOnly: true,
           secure: true,
           sameSite: 'strict',
       });
       const [name, domain] = payload.email.split('@');
       const [host] = domain.split('.');
       await createCustomerIfNeeded({
           email: payload.email,
           firstName: name.split('.')[0] || 'Unknown',
           lastName: name.split('.')[1] || 'Unknown',
           companyName: host.toUpperCase(),
       });
   } catch {
       // console.error(error)
       return new Response(`Unauthorized.`, { status: 401 });
   }
   redirect(redirectTo);
}

It is also worth noting that customer creation can happen at different points in the flow. For simplicity, we create a Customer in Crystallize at this stage using only the available information—the email address—while setting the first name, last name, and company name as placeholders. This approach keeps the process straightforward, but in a real-world scenario, additional user details could be collected at a later stage.

The last step is the Next.js Middleware.

export async function middleware(request: NextRequest) {
   const redirectTo = (to: string) =>
       NextResponse.redirect(new URL(request.nextUrl.href.replace(request.nextUrl.pathname, to)));

   const cookie = request.cookies.get('auth.token');
   if (!cookie) {
       return redirectTo('/login');
   }
   const token = cookie.value;
   try {
       const authenticator = createAuthenticator({
           authSecret: `${process.env.AUTH_SECRET}`,
       });
       // we don't have to do anything with the payload here
       // we just verify that the token is valid
       await authenticator.decodeToken(token);
   } catch {
       return redirectTo('/login');
   }
   return NextResponse.next();
}

export const config = {
   matcher: ['/my/subscriptions'],
};

There’s not much to elaborate on—we simply check if the token exists and validate it before allowing it to pass.

That’s it with Authentication!

Creating the Subscription Contract

When a user subscribes, we must create a subscription contract in Crystallize. The flow is straightforward:

  • The user selects a subscription plan, including the product, SKU, pricing, and billing period.
  • They proceed to checkout, where they provide their billing details.
  • Once they confirm, we trigger an action in Next.js that takes all this information and prepares the contract.
  • Instead of manually constructing the contract from scratch, we use Crystallize’s JS API client to obtain a subscription contract template. This template is essentially a pre-filled version of the contract with all relevant details, so we don’t have to piece everything together manually.
  • At this point, we can modify the contract if necessary—for example, we can apply custom discounts, override prices, or tweak specific contract terms.
  • Once finalized, we send it to Crystallize, which stores it alongside the customer.

In the boilerplate, this flow is in 2 steps. When the user clicks on the “subscribe button,” we create an object representing the choice.

const choice: SubscriptionChoice = {
   path: subscription.path,
   plan: plan.identifier,
   period: period.id,
   priceVariant: “default”,
   sku: variant.sku,
};

These are the essential details required to create a Subscription Contract. Once gathered, we encode and pass this selection to the checkout page, where the user finalizes their subscription.

We won’t explore the checkout page's UI. Instead, we’ll focus on the essentials of creating the Subscription Contract. As expected, this process involves a Form and a Server Action.

At this stage, the Server Action is called subscribeAction, and here’s the code:

export async function subscribeAction(extras: { link: string }, prevState: unknown, form: FormData) {
   let redirectTo = `${extras.link}?success=true`;
   const cookieStore = await cookies();
   const token = cookieStore.get('auth.token')?.value;
   try {
       const payload = await authenticator.decodeToken(token || '');
       const sku = form.get('sku') as string;
       const customerIdentifier = payload.email;
       await subscribe({
           choice: {
               path: form.get('path') as string,
               sku,
               plan: form.get('plan') as string,
               period: form.get('period') as string,
               priceVariant: form.get('priceVariant') as string,
           },
           language: 'en',
           customerIdentifier,
       });
   } catch (exception) {
       console.error(exception);
       redirectTo = '/login';
   }
   // redirect cannot be done in try-catch block... as internally it does throw.... Next.js black magick
   redirect(redirectTo);

Essentially, we check the token for security and call the use-case subscribe.

const { path, plan, period, priceVariant, sku } = choice;
       const template = await subscriptionContractManager.createSubscriptionContractTemplateBasedOnVariantIdentity(
           path,
           {
               sku,
           },
           plan,
           period,
           priceVariant,
           language,
       );
       // we are going to be opinionated here and set the subscription to be active immediately
       // and with a custom renewal date in 3 days from now
       const inThreeDays = new Date();
       inThreeDays.setDate(inThreeDays.getDate() + 3);
       inThreeDays.setHours(23, 59, 59, 999);
       const { recurring, initial, ...rest } = template;
       await subscriptionContractManager.create({
           ...rest,
           ...(initial ? { initial } : {}),
           recurring,
           customerIdentifier,
           tenantId: crystallizeClient.config.tenantId!,
           status: {
               activeUntil: inThreeDays,
               price: template.initial?.price || template.recurring?.price || 0,
               currency: template.initial?.currency || template.recurring?.currency || 'EUR',
               renewAt: inThreeDays,
           },
       });

As explained, we start by retrieving a Subscription Contract template, which serves as a base structure. This template can be modified as needed—for example, adjusting the price, adding metadata, or setting a custom renewal date. Once the necessary changes are made, we use the subscriptionContractManager to finalize and create the contract in Crystallize.

At this step, the user is redirected to the Thank you page!

On the Flip Side, the Backend Events

Once the Subscription Contract is created, Crystallize will trigger an event—provided the webhooks are correctly configured. You need to set up four webhooks, all pointing to the corresponding /webhooks endpoints in the boilerplate:

  • On Contract Created – Triggered when a new subscription contract is created.
  • On Contract Renew – Fires when a contract reaches its renewal date.
  • On Order Created – Activated when an order is generated, either at the initial purchase or during renewal.
  • On Order Pipeline Changed – Fired when an order moves through different processing stages, such as payment attempts or retries.

Each webhook’s corresponding GraphQL query can be found in the boilerplate webhooks folder. These queries ensure the endpoint receives all the relevant data so that it can act accordingly.

Now, we need to implement each endpoint following the flow we described earlier.

On Contract Created

We start with:

export async function POST(request: Request) {
   const body = await request.json();
   const contract = body.subscriptionContract.get;
   const bill = await computeContractBill({
       contract,
       period: {
           from: new Date(parseInt(contract.id.substring(0, 8), 16) * 1000),
           to: new Date(),
       },
   });
   const orderConfirmation = await createNewOrderFromContractAndUsage(contract, bill);
   return NextResponse.json({
       contract,
       orderConfirmation,
   });
}

We compute the price (the way we want) and create an Order!

async (contract: SubscriptionContract, bill: Bill) => {
       // we take the first currency of the grandtotal
       const netPrice = bill.price;
       const billingAddress =
           contract?.addresses?.find((address) => address.type.value === 'billing') ||
           contract?.customer?.addresses?.find((address) => address.type.value === 'billing') ||
           contract?.addresses?.[0] ||
           contract?.customer?.addresses?.[0];
       const taxRate = billingAddress && billingAddress.country?.toLowerCase() === 'norway' ? 0.25 : 0;
       const applyTax = (price: number) => {
           return price * (1 + taxRate);
       };

       // price is the same here, Order Item, Sub Total and Total
       const price = {
           gross: applyTax(netPrice),
           net: netPrice,
           currency: bill.currency,
           tax: {
               name: 'VAT',
               percent: taxRate * 100,
           },
       };

       const intent: CreateOrderInputRequest = {
           customer: {
               identifier: contract.customerIdentifier,
               ...(contract.customer && {
                   firstName: contract.customer.firstName,
                   lastName: contract.customer.lastName,
                   companyName: contract.customer.companyName,
               }),
               addresses: [
                   //@ts-expect-error - It's an enum in the API
                   removeNullValue({
                       ...billingAddress,
                       type: 'billing',
                   }),
               ],
           },
           cart: [
               {
                   quantity: 1,
                   name: contract.item.name,
                   sku: contract.item.sku,
                   price,
                   subTotal: price,
                   subscriptionContractId: contract.id,
                   subscription: {
                       name: contract.subscriptionPlan.name,
                       period: bill.phase.period,
                       //@ts-expect-error - It's an enum in the API
                       unit: bill.phase.unit,
                       start: bill.range.from,
                       end: bill.range.to,
                       meteredVariables: Object.keys(bill.variables).map((key) => {
                           const variable = bill.variables[key];
                           return {
                               id: variable.id,
                               usage: variable.usage,
                               price: applyTax(variable.price),
                           };
                       }),
                   },
               },
           ],
           total: price,
           meta: [{ key: 'email', value: contract.customer?.email || contract.customerIdentifier }],
       };
       return await orderManager.push(intent);
   };

There’s nothing complex here—the JS API Client streamlines creating an Order with minimal code.

Once the Order is created (asynchronously in Crystallize), another event is triggered, allowing the system to proceed with the next steps in the subscription flow.

On Order Created

We start with:

export async function POST(request: Request) {
   const body = await request.json();
   const order = body.order.get as Order;
   const paymentStatus = Math.random() > 0.5 ? 'success' : 'failed';
   await updateOrderPaymentStatus(order, paymentStatus, 'OrderCreated');
   return NextResponse.json({
       orderId: order.id,
   });
}

We can hardly make this simpler. We bypass the payment system here, and we introduce a random status. Only 50% of the time the payment will be successful.

async (order: Order, paymentStatus: 'success' | 'failed', from: string) => {
       await orderManager.updatePayment(order.id, {
           payment: [
               {
                   provider: 'custom',
                   custom: {
                       properties: [
                           {
                               property: 'payment_method',
                               value: 'Crystal Coin',
                           },
                           {
                               property: 'wallet_id',
                               value: order.customer.identifier,
                           },
                           {
                               property: 'amount',
                               value: String(order.total?.gross || 0),
                           },
                           {
                               property: 'status',
                               value: paymentStatus,
                           },
                           {
                               property: 'via',
                               value: from,
                           },
                       ],
                   },
               },
           ],
       });

       await orderManager.updatePipelineStage(
           order.id,
           pipelineConfiguration.paymentFlow.id,
           paymentStatus === 'failed'
               ? pipelineConfiguration.paymentFlow.stages.failed
               : pipelineConfiguration.paymentFlow.stages.success,
       );

At this point, we update the payment status and move the Order to the appropriate Pipeline stage based on its status.

Note on Payment Processing

In a real-world scenario, you would integrate with a payment provider to charge or capture the payment. Typically, the payment system would call a separate endpoint to notify you of the transaction status. That callback would be responsible for updating the Order accordingly—just like we’re doing here.

However, since this blog post focuses on Subscription Commerce with Crystallize rather than payment processing, we’ve kept things simple.

It’s worth noting that we don’t listen for an Order Update event; instead, we only listen for changes in the Order Pipeline Stage.

This ensures that our logic is triggered only when the order progresses through predefined stages rather than on every minor update to the order data. It keeps the workflow clean and avoids unnecessary processing.

Before diving deeper, there’s one more crucial endpoint we haven’t covered yet—the renewal event—which is at the core of subscription management!

Crystallize automates the contract renewal process by triggering a webhook whenever a contract is renewed. Your service API will be called every time this happens, allowing you to take the necessary actions.

What do we need to do? It's simple. We compute the price for the renewal period and create a new order. Once the order is created, the Order Created event fires again, and the process repeats itself, following the same flow as the initial subscription.

Do you recall doing something similar earlier? Yes! The renewal flow is almost identical to the Contract Created flow. And it makes perfect sense—Contract Creation is just the first iteration of the subscription lifecycle.

The only real difference? The period for which we compute the price.

export async function POST(request: Request) {
   const body = await request.json();
   const contract = body.subscriptionContract.get;
   const bill = await computeContractBill({ contract });
   const orderConfirmation = await createNewOrderFromContractAndUsage(contract, bill);
   return NextResponse.json({
       contract,
       orderConfirmation,
   });
}

Isn’t it a beauty? The same logic applies, making the system consistent, predictable, and easy to maintain.

On Order Pipeline Changed

We start with:

export async function POST(request: Request) {
   const body = await request.json();
   const order = body.order.get as Order;
   const pipelineEvent = body.pipeline.get;
   const results = await handlerOrderPipelineChange(order, pipelineEvent, body.order.get.pipelines);
   if (results && 'orderId' in results) {
       return NextResponse.json(results);
   }
   return new Response(`Unauthorized.`, { status: 401 });
}

The key concepts for handling orders and payments are in the handlerOrderPipelineChange use case.

Goals of the Order Pipeline Change Handling

  • When a payment is successful, the order moves to the Success stage.
  • When a payment fails, the order moves to the Failed stage.

By listening to these stage changes, we can act accordingly:

  • Send emails to notify the customer.
  • Trigger alerts for internal teams.

Implement a retry mechanism for failed payments.

A Simple and Effective Retry Mechanism

One of the most recommended retry approaches involves a staged pipeline, which could look like this:

  • Processing → Handling payment as expected (waiting for the payment system)
  • Failed → Payment failure detected, notifying the admin.
  • Success → Payment confirmed, order completed, notifications
  • To Capture → Trigger a new capture.

Handling Payment Failures Manually

When an order moves to the Failed stage, an Admin can check the error details, call the customer, or take necessary action.

Crystallize makes retrying the payment as simple as drag and drop.

Moving the Failed order into the Recapture stage triggers a stage change event. This event calls the appropriate endpoint in your service API, allowing for a seamless retry process.

   order: Order,
       pipelineEvent: { id: string },
       orderPipelines: {
           pipeline: {
               id: string;
           };
           stageId: string;
       }[],
   ) => {
       if (pipelineEvent.id !== pipelineConfiguration.paymentFlow.id) {
           return new Response(`Unauthorized.`, { status: 401 });
       }
       const orderPipelineStageId = orderPipelines.find(
           (p: {
               pipeline: {
                   id: string;
               };
               stageId: string;
           }) => {
               return p.pipeline.id === pipelineConfiguration.paymentFlow.id;
           },
       )?.stageId;

       switch (orderPipelineStageId) {
           case pipelineConfiguration.paymentFlow.stages.toCapture:
               const paymentStatus = Math.random() > 0.3 ? 'success' : 'failed';
               await updateOrderPaymentStatus(order, paymentStatus, 'OrderReCapture');
               await orderManager.updatePipelineStage(
                   order.id,
                   pipelineConfiguration.paymentFlow.id,
                   paymentStatus === 'failed'
                       ? pipelineConfiguration.paymentFlow.stages.failed
                       : pipelineConfiguration.paymentFlow.stages.success,
               );
               return {
                   orderId: order.id,
                   status: paymentStatus,
               };
           case pipelineConfiguration.paymentFlow.stages.processing:
           case pipelineConfiguration.paymentFlow.stages.failed:
           case pipelineConfiguration.paymentFlow.stages.success: {
               //do something here
               return {
                   orderId: order.id,
               };
           }
           default:
       }
       return;

Everything stays clean and maintainable since we’re just reusing the existing use case. There is no need to reinvent the wheel—the same flow seamlessly handles initial purchases and renewals.

Closing the Subscription Flow

At this point, we’ve covered:

Subscription Contract creation
Order creation
Payment handling
Renewal processing
Retry mechanisms for failed payments

With the full Subscription Flow in place, the last thing we need to do is display the user’s existing Subscriptions and Orders.

Let’s move on to that!

My Subscription Page

This is a very standard approach here. We have a page fetching data in Crystallize for the logged-in user; the Middleware protects this page.

export default async function MySubscriptions() {
   // this could be put in a sub RSC and suspense to load faster
   const cookieStore = await cookies();
   const token = cookieStore.get('auth.token')?.value;
   const payload = await authenticator.decodeToken(token || '');
   const { orders, contracts, me } = await fetchMyAccountData(payload.email);

The use case, once again, leverages the JS API Client.

async (email: string) => {
       const [me, contractResults, orderResults] = await Promise.all([
           retrieveMeData(email),
           subscriptionContractManager.fetchByCustomerIdentifier(email),
           orderManager.fetch.byCustomerIdentifier(
               email,
               undefined,
               undefined,
               {
                   subscriptionContractId: true,
                   subscription: {
                       start: true,
                       end: true,
                       name: true,
                       period: true,
                       unit: true,
                       meteredVariables: {
                           id: true,
                           usage: true,
                           price: true,
                       },
                   },
               },
               {
                   reference: true,
               },
           ),
       ]);

       const orders = orderResults.orders as Array<Order & { reference: string }>;
       const contracts = contractResults.contracts;
       return {
           orders,
           contracts,
           me,
       };
   };

Then it’s just UI!

Conclusion

Setting up a Subscription Commerce system with Crystallize is both flexible and powerful. Throughout this guide, we’ve walked through the essential steps—from authentication and subscription contract creation to order processing, payment handling, and renewals.

The key takeaway? Crystallize gives you complete control over your subscription logic while handling the heavy lifting for recurring billing and contract management. We've built a scalable and maintainable subscription flow by leveraging webhooks, pipelines, and modular architecture.

TL; DR

  • Users authenticate via Magic Links, enabling a frictionless login experience.
  • Subscription contracts are created based on user selections, defining recurring billing terms.
  • Orders are generated both for initial purchases and renewals.
  • Payment processing is managed, and failures trigger retry mechanisms.
  • Renewals are handled automatically, ensuring seamless continuity for subscribers.
  • Admins can intervene when payments fail using Crystallize’s order pipeline system.

The beauty of this setup is its reusability—the same principles apply whether it’s a first-time purchase or a renewal. And because Crystallize is non-opinionated, you can customize every aspect of the flow to fit your business needs.

Now that the full subscription flow is in place, the last step is to display active subscriptions and users' order history. From here, you can extend the system further by integrating advanced pricing models, metered usage billing, or multi-currency support.

Subscription commerce is not just about recurring billing—it’s about delivering seamless experiences for customers while keeping operations efficient and automated. With Crystallize, you get the best of both worlds.

Time to start building! 🚀

You can install this fully functional accelerator using our CLI in 1 command with our CLI:

~/crystallize boilerplate install ./nerd-factory my-nerd-factory nerd-factory-boilerplate

If you don’t have it yet:

curl -LSs https://crystallizeapi.github.io/cli/install.bash | bash

For a visual walkthrough, you can watch the following livestream: