Back

Remixing the Shopware Checkout (Part 1)

Remixing the Shopware Checkout (Part 1)

Vinyl record player on top of a desk in a living room

In this blog post I am going to attempt to build a checkout using Shopware's Store API and Remix.

This article is part one of a two-part series. In the first part I'll show you how to build the basic layout, explain the communication between the client and the API and how to fetch data from the API. In the second part I'll show you how to mutate data, handle errors and maybe some advanced topics like caching and streaming.

Why Remix?

Remix allows developers to build full-stack web applications starting from a minimal setup which we can progressively enhance with features. I figured it would be a good idea to apply this concept to the Shopware checkout.

So why full-stack you might ask? Because Remix acts on both sides. But not in the way you might expect. In a classic scenario there is an API that provides state and mutations and the frontends calls that API to receive data and react upon user interactions. In Remix these two parts are the same. Technically, the UI is its own API - as in - it defines which data it presents and mutates. So whatever API, database or store is called in the background is no more relevant for the frontend, because it defines its own API.

It goes so far that Remix doesn't require any state management within your components, because all boundaries are clearly defined - it even works without client side javascript as it's built upon browser-native APIs such as the form element or link prefetch. Javascript can be added to "progressively enhance" the experience, but is not required. I was captured by that approach, so I wanted to give it a try.

That being said, I am not an expert in Remix, I'm just sharing my insight after some basic tutorials and adapt that onto a thing or two I know about Shopware. The nice thing about Remix though is that, having a traditional web dev background, I was able to pick up the basics really quick. So if you are interested in Remix, I'd recommend to give it a try.

Initial thoughts

Before we get going, let's have a look at a common checkout structure

A common checkout structure showing customer information form fields and the order summary

We see a big form, collecting information such as

  • Customer details (email address)
  • Shipping details (name, address, phone number)
  • Delivery method (select boxes)
  • Payment details (payment method, bank details)

An additional consideration is that this data will be dynamic - so e.g. changing the country for shipping will alter the available delivery methods or taxes. That means we have to ensure that the UI is always updated accordingly.

In order to display the required information the app has to perform multiple API requests

  • Get the cart
  • Get the available shipping and payment options
  • Get the available countries

The same applies to mutations - in order to change the shipping method, the app has to perform a request to the API. The diagram below shows the different requests being made and propagated through the app. If you are used to the client calling the API directly it might seem a bit cumbersome, but you'll see how it can make your life easier and our app more robust in the end.

A diagram showing the communication between the client, server and the API

Getting started

So let's get started coding, won't we? First, we'll create a new project.

npx create-remix@latest

? Where would you like to create your app? remixing-shopware
? What type of app do you want to create? Just the basics
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's
easy to change deployment targets. Remix App Server
? TypeScript or JavaScript? TypeScript
? Do you want me to run `npm install`? Yes

Next, let's also add TailwindCSS to our project. We'll use it to style our app.

I won't go through the required steps in detail, just make sure you follow the same steps as described in the TailwindCSS docs.

Our first route

Next, create a file name app/routes/checkout.tsx and add the following code

app/routes/checkout.tsx
app/routes/checkout.tsx
export default function CheckoutPage() {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";
  const deliveryClasses = "border border-gray-300 bg-white rounded-md p-5 mt-6 w-full flex justify-between";

  return (
    <form method="post">
      <div className={"grid md:grid-cols-2 gap-16 mx-auto max-w-screen-xl my-20"}>
        <div id="checkout-information">
          <div>
            <h2 className={headingClasses}>Customer information</h2>
            <label className={labelClasses} htmlFor="email">Email Address</label>
            <input className={inputClasses} type="email" name="email" id="email" />
          </div>
          
          <div className={"mt-10 border-t border-t-slate-300"}>
            <h2 className={headingClasses}>Shipping details</h2>
            <div className={"grid grid-cols-2 gap-4"}>
              <div>
                <label className={labelClasses} htmlFor="first-name">First Name</label>
                <input className={inputClasses} type="text" name="first-name" id="first-name" />
              </div>
              <div>
                <label className={labelClasses} htmlFor="last-name">Last Name</label>
                <input className={inputClasses} type="text" name="last-name" id="last-name" />
              </div>
            </div>
            <div>
              <label className={labelClasses} htmlFor="address">Address Line 1</label>
              <input className={inputClasses} type="text" name="address" id="address" />
            </div>
            <div>
              <label className={labelClasses} htmlFor="address-2">Address Line 2</label>
              <input className={inputClasses} type="text" name="address-2" id="address-2" />
            </div>
            <div className={"grid grid-cols-2 gap-4"}>
              <div>
                <label className={labelClasses} htmlFor="city">City</label>
                <input className={inputClasses} type="text" name="city" id="city" />
              </div>
              <div>
                <label className={labelClasses} htmlFor="state">State</label>
                <input className={inputClasses} type="text" name="state" id="state" />
              </div>
              <div>
                <label className={labelClasses} htmlFor="country">Country</label>
                <select className={inputClasses} name="country" id="country">
                  <option value="US">United States</option>
                </select>
              </div>
              <div>
                <label className={labelClasses} htmlFor="zip">Zip</label>
                <input className={inputClasses} type="text" name="zip" id="zip" />
              </div>
            </div>
          </div>

          <div className={"mt-10 border-t border-t-slate-300"}>
            <h2 className={headingClasses}>Delivery method</h2>
            <div className="grid grid-cols-2 gap-4">
              <div className={`${deliveryClasses}`}>
                <div>
                  <label className="font-bold block mb-1" htmlFor="delivery-method-1">Standard</label>
                  <div>Delivery in 3-5 days</div>
                </div>
                <input type="radio" name="delivery-method" id="delivery-method-1" value="delivery-method-1" />
              </div>
              <div className={`${deliveryClasses}`}>
                <div>
                  <label className="font-bold block mb-1" htmlFor="delivery-method-2">Express</label>
                  <div>Delivery in 1-3 days</div>
                </div>
                <input type="radio" name="delivery-method" id="delivery-method-2" value="delivery-method-2" />
              </div>
            </div>
          </div>

          <div className={"mt-10 border-t border-t-slate-300"}>
            <h2 className={headingClasses}>Payment method</h2>
            <div className="flex gap-10">
              <div className="flex gap-4 mt-4">
                <input type="radio" name="payment-method" id="payment-method-1" value="payment-method-1" />
                <label className={""} htmlFor="payment-method-1">Credit Card</label>
              </div>
              <div className="flex gap-4 mt-4">
                <input type="radio" name="payment-method" id="payment-method-2" value="payment-method-2" />
                <label className={""} htmlFor="payment-method-2">PayPal</label>
              </div>
              <div className="flex gap-4 mt-4">
                <input type="radio" name="payment-method" id="payment-method-3" value="payment-method-3" />
                <label className={""} htmlFor="payment-method-3">Apple Pay</label>
              </div>
            </div>
          </div>
        </div>
        <div id="checkout-summary">
          <h3 className={headingClasses}>Order summary</h3>
          <div className={"border border-gray-300 rounded-md mt-6 w-full bg-white flex flex-col divide-y"}>

            <div className="flex gap-6 p-6">
              <div className="w-24 h-32 bg-slate-200 rounded-md"></div>
              <div className="grow flex justify-between">
                <div className="flex flex-col h-full justify-between">
                  <div>
                    <div className="">Shirt</div>
                    <div className="font-light text-slate-600">Black</div>
                    <div className="font-light text-slate-600">Large</div>
                  </div>
                  <div>
                    <div className="font-semibold">$20.00</div>
                  </div>
                </div>
                <div className="flex flex-col h-full justify-between">
                  <button className="text-sm">Remove</button>
                  <div className="flex gap-4">
                    <button className="text-sm">-</button>
                    <div className="text-sm">1</div>
                    <button className="text-sm">+</button>
                  </div>
                </div>
              </div>
            </div>

            <div className="flex gap-6 p-6">
              <div className="w-24 h-32 bg-slate-200 rounded-md"></div>
              <div className="grow flex justify-between">
                <div className="flex flex-col h-full justify-between">
                  <div>
                    <div className="">Shirt</div>
                    <div className="font-light text-slate-600">Black</div>
                    <div className="font-light text-slate-600">Large</div>
                  </div>
                  <div>
                    <div className="font-semibold">$20.00</div>
                  </div>
                </div>
                <div className="flex flex-col h-full justify-between">
                  <button className="text-sm">Remove</button>
                  <div className="flex gap-4">
                    <button className="text-sm">-</button>
                    <div className="text-sm">1</div>
                    <button className="text-sm">+</button>
                  </div>
                </div>
              </div>
            </div>

            <div className="p-6 flex flex-col gap-6">

              <div className="flex justify-between">
                <div className="text-sm">Subtotal</div>
                <div className="text-sm">$40.00</div>
              </div>
              <div className="flex justify-between">
                <div className="text-sm">Shipping</div>
                <div className="text-sm">$5.00</div>
              </div>
              <div className="flex justify-between">
                <div className="text-sm">Tax</div>
                <div className="text-sm">$3.00</div>
              </div>

            </div>

            <div className="p-6 flex flex-col gap-6">

              <div className="flex justify-between">
                <div className="text-sm font-semibold">Total</div>
                <div className="text-sm font-semibold">$48.00</div>
              </div>

            </div>

            <div className="p-6 flex flex-col gap-6">

              <button className="bg-indigo-600 text-white rounded-md py-3 px-6">Place order</button>

            </div>

          </div>
        </div>
      </div>
    </form>
  );
}

We've got a layout similar to the sketch further up, neat! But for now it's completely static. The nice thing about Remix is that we can add interactivity in small increments without having to do a lot of changes to the existing code. And another thing - we don't have to worry about state in here, because everything is already stored by the browser. In general, Remix operates close to core web priniciples.

Let's start with the obvious thing - extract some of the markup into components. We'll start with the summary-product component. Start by creating a new file app/components/checkout/summary-product.tsx and add the following code:

app/components/checkout/summary-product.tsx
export default function SummaryProduct({
  product
}: {
  product: {
    id: string,
    name: string,
    price: string,
    quantity: number,
    options: Array<string>
  }
}) {
  return (
    <div className="flex gap-6 p-6">
      <div className="w-24 h-32 bg-slate-200 rounded-md"></div>
      <div className="grow flex justify-between">
        <div className="flex flex-col h-full justify-between">
          <div>
            <div className="">{product.name}</div>
            {product.options.map((option) => (
              <div className="font-light text-slate-600">{option}</div>
            ))}
          </div>
          <div>
            <div className="font-semibold">${product.price}</div>
          </div>
        </div>
        <div className="flex flex-col h-full justify-between">
          <button className="text-sm">Remove</button>
          <div className="flex gap-4">
            <button className="text-sm">-</button>
            <div className="text-sm">{product.quantity}</div>
            <button className="text-sm">+</button>
          </div>
        </div>
      </div>
    </div>
  );
}

Now we can replace the product markup in our checkout page with the new component

{products.map((product) => (
  <SummaryProduct key={product.id} product={product} />
))}

of course this isn't gonna work, because products is not defined at this point. Now, the interesting part begins - we're going to define our first loader. A loader is basically the API of the page. It's a function that returns data that is used to render the page.

We define loaders like this:

export const loader = async ({}: LoaderArgs) => {
  const products = [{
    id: "1",
    name: "Shirt",
    price: "20.00",
    quantity: 1,
    options: ["Black", "Large"]
  },
  {
    id: "2",
    name: "Pants",
    price: "30.00",
    quantity: 1,
    options: ["Blue", "Medium"]
  }];

  return json({
    products
  });
}

What's cool about this? Loaders are called on the server, so we can do all kinds of things here, like fetching data from an API, or a database. We can also prepare or reformat the data before we send it to the client - this is a great place to do things like caching, integrating data from various systems, or even A/B testing.

In our component we can then use the loader's data using the useLoaderData hook.

const { products } = useLoaderData<typeof loader>();

The fully working code looks like this

app/routes/checkout.tsx
import { json, LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import SummaryProduct from "~/components/checkout/summary-product";

export const loader = async ({}: LoaderArgs) => {
  const products = [{
    id: "1",
    name: "Shirt",
    price: "20.00",
    quantity: 1,
    options: ["Black", "Large"]
  },
  {
    id: "2",
    name: "Pants",
    price: "30.00",
    quantity: 1,
    options: ["Blue", "Medium"]
  }];

  return json({
    products
  });
}

export default function CheckoutPage() {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";
  const deliveryClasses = "border border-gray-300 bg-white rounded-md p-5 mt-6 w-full flex justify-between";

  const { products } = useLoaderData<typeof loader>();

  return (
    <form method="post">
      <div className={"grid md:grid-cols-2 gap-16 mx-auto max-w-screen-xl my-20"}>
        <div id="checkout-information">
          <div>
            <h2 className={headingClasses}>Customer information</h2>
            <label className={labelClasses} htmlFor="email">Email Address</label>
            <input className={inputClasses} type="email" name="email" id="email" />
          </div>
          
          <div className={"mt-10 border-t border-t-slate-300"}>
            <h2 className={headingClasses}>Shipping details</h2>
            <div className={"grid grid-cols-2 gap-4"}>
              <div>
                <label className={labelClasses} htmlFor="first-name">First Name</label>
                <input className={inputClasses} type="text" name="first-name" id="first-name" />
              </div>
              <div>
                <label className={labelClasses} htmlFor="last-name">Last Name</label>
                <input className={inputClasses} type="text" name="last-name" id="last-name" />
              </div>
            </div>
            <div>
              <label className={labelClasses} htmlFor="address">Address Line 1</label>
              <input className={inputClasses} type="text" name="address" id="address" />
            </div>
            <div>
              <label className={labelClasses} htmlFor="address-2">Address Line 2</label>
              <input className={inputClasses} type="text" name="address-2" id="address-2" />
            </div>
            <div className={"grid grid-cols-2 gap-4"}>
              <div>
                <label className={labelClasses} htmlFor="city">City</label>
                <input className={inputClasses} type="text" name="city" id="city" />
              </div>
              <div>
                <label className={labelClasses} htmlFor="state">State</label>
                <input className={inputClasses} type="text" name="state" id="state" />
              </div>
              <div>
                <label className={labelClasses} htmlFor="country">Country</label>
                <select className={inputClasses} name="country" id="country">
                  <option value="US">United States</option>
                </select>
              </div>
              <div>
                <label className={labelClasses} htmlFor="zip">Zip</label>
                <input className={inputClasses} type="text" name="zip" id="zip" />
              </div>
            </div>
          </div>

          <div className={"mt-10 border-t border-t-slate-300"}>
            <h2 className={headingClasses}>Delivery method</h2>
            <div className="grid grid-cols-2 gap-4">
              <div className={`${deliveryClasses}`}>
                <div>
                  <label className="font-bold block mb-1" htmlFor="delivery-method-1">Standard</label>
                  <div>Delivery in 3-5 days</div>
                </div>
                <input type="radio" name="delivery-method" id="delivery-method-1" value="delivery-method-1" />
              </div>
              <div className={`${deliveryClasses}`}>
                <div>
                  <label className="font-bold block mb-1" htmlFor="delivery-method-2">Express</label>
                  <div>Delivery in 1-3 days</div>
                </div>
                <input type="radio" name="delivery-method" id="delivery-method-2" value="delivery-method-2" />
              </div>
            </div>
          </div>

          <div className={"mt-10 border-t border-t-slate-300"}>
            <h2 className={headingClasses}>Payment method</h2>
            <div className="flex gap-10">
              <div className="flex gap-4 mt-4">
                <input type="radio" name="payment-method" id="payment-method-1" value="payment-method-1" />
                <label className={""} htmlFor="payment-method-1">Credit Card</label>
              </div>
              <div className="flex gap-4 mt-4">
                <input type="radio" name="payment-method" id="payment-method-2" value="payment-method-2" />
                <label className={""} htmlFor="payment-method-2">PayPal</label>
              </div>
              <div className="flex gap-4 mt-4">
                <input type="radio" name="payment-method" id="payment-method-3" value="payment-method-3" />
                <label className={""} htmlFor="payment-method-3">Apple Pay</label>
              </div>
            </div>
          </div>
        </div>
        <div id="checkout-summary">
          <h3 className={headingClasses}>Order summary</h3>
          <div className={"border border-gray-300 rounded-md mt-6 w-full bg-white flex flex-col divide-y"}>

            {products.map((product) => (
              <SummaryProduct key={product.id} product={product} />
            ))}

            <div className="p-6 flex flex-col gap-6">

              <div className="flex justify-between">
                <div className="text-sm">Subtotal</div>
                <div className="text-sm">$40.00</div>
              </div>
              <div className="flex justify-between">
                <div className="text-sm">Shipping</div>
                <div className="text-sm">$5.00</div>
              </div>
              <div className="flex justify-between">
                <div className="text-sm">Tax</div>
                <div className="text-sm">$3.00</div>
              </div>

            </div>

            <div className="p-6 flex flex-col gap-6">

              <div className="flex justify-between">
                <div className="text-sm font-semibold">Total</div>
                <div className="text-sm font-semibold">$48.00</div>
              </div>

            </div>

            <div className="p-6 flex flex-col gap-6">

              <button className="bg-indigo-600 text-white rounded-md py-3 px-6">Place order</button>

            </div>

          </div>
        </div>
      </div>
    </form>
  );
}

Clean up the files

To further clean up the code, we can do the same thing with the customer details, shipping information, shipping selection, and payment selection. We also create a component for the order summary.

app/components/checkout/customer-information.tsx
export default function CustomerInformation() {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";

  return (
    <div>
      <h2 className={headingClasses}>Customer information</h2>
      <label className={labelClasses} htmlFor="email">Email Address</label>
      <input className={inputClasses} type="email" name="email" id="email" />
    </div>
  )
}
app/components/checkout/shipping-details.tsx
export default function ShippingDetails() {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";
  
  return (
    <div className={"mt-10 border-t border-t-slate-300"}>
      <h2 className={headingClasses}>Shipping details</h2>
      <div className={"grid grid-cols-2 gap-4"}>
        <div>
          <label className={labelClasses} htmlFor="first-name">First Name</label>
          <input className={inputClasses} type="text" name="first-name" id="first-name" />
        </div>
        <div>
          <label className={labelClasses} htmlFor="last-name">Last Name</label>
          <input className={inputClasses} type="text" name="last-name" id="last-name" />
        </div>
      </div>
      <div>
        <label className={labelClasses} htmlFor="address">Address Line 1</label>
        <input className={inputClasses} type="text" name="address" id="address" />
      </div>
      <div>
        <label className={labelClasses} htmlFor="address-2">Address Line 2</label>
        <input className={inputClasses} type="text" name="address-2" id="address-2" />
      </div>
      <div className={"grid grid-cols-2 gap-4"}>
        <div>
          <label className={labelClasses} htmlFor="city">City</label>
          <input className={inputClasses} type="text" name="city" id="city" />
        </div>
        <div>
          <label className={labelClasses} htmlFor="state">State</label>
          <input className={inputClasses} type="text" name="state" id="state" />
        </div>
        <div>
          <label className={labelClasses} htmlFor="country">Country</label>
          <select className={inputClasses} name="country" id="country">
            <option value="US">United States</option>
          </select>
        </div>
        <div>
          <label className={labelClasses} htmlFor="zip">Zip</label>
          <input className={inputClasses} type="text" name="zip" id="zip" />
        </div>
      </div>
    </div>
  )
}
app/components/checkout/shipping-selection.tsx
export default function ShippingSelection() {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const deliveryClasses = "border border-gray-300 bg-white rounded-md p-5 mt-6 w-full flex justify-between";

  return (
    <div className={"mt-10 border-t border-t-slate-300"}>
      <h2 className={headingClasses}>Delivery method</h2>
      <div className="grid grid-cols-2 gap-4">
        <div className={`${deliveryClasses}`}>
          <div>
            <label className="font-bold block mb-1" htmlFor="delivery-method-1">Standard</label>
            <div>Delivery in 3-5 days</div>
          </div>
          <input type="radio" name="delivery-method" id="delivery-method-1" value="delivery-method-1" />
        </div>
        <div className={`${deliveryClasses}`}>
          <div>
            <label className="font-bold block mb-1" htmlFor="delivery-method-2">Express</label>
            <div>Delivery in 1-3 days</div>
          </div>
          <input type="radio" name="delivery-method" id="delivery-method-2" value="delivery-method-2" />
        </div>
      </div>
    </div>
  )
};
app/components/checkout/payment-selection.tsx
export default function PaymentSelection() {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  
  return (
    <div className={"mt-10 border-t border-t-slate-300"}>
      <h2 className={headingClasses}>Payment method</h2>
      <div className="flex gap-10">
        <div className="flex gap-4 mt-4">
          <input type="radio" name="payment-method" id="payment-method-1" value="payment-method-1" />
          <label className={""} htmlFor="payment-method-1">Credit Card</label>
        </div>
        <div className="flex gap-4 mt-4">
          <input type="radio" name="payment-method" id="payment-method-2" value="payment-method-2" />
          <label className={""} htmlFor="payment-method-2">PayPal</label>
        </div>
        <div className="flex gap-4 mt-4">
          <input type="radio" name="payment-method" id="payment-method-3" value="payment-method-3" />
          <label className={""} htmlFor="payment-method-3">Apple Pay</label>
        </div>
      </div>
    </div>
  )
}
app/components/checkout/summary.tsx
import SummaryProduct from "~/components/checkout/summary-product";

export default function Summary({ products }: { products: any }) {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";

  return (
    <div id="checkout-summary">
      <h3 className={headingClasses}>Order summary</h3>
      <div className={"border border-gray-300 rounded-md mt-6 w-full bg-white flex flex-col divide-y"}>

        {products.map((product: any) => (
          <SummaryProduct key={product.id} product={product} />
        ))}

        <div className="p-6 flex flex-col gap-6">

          <div className="flex justify-between">
            <div className="text-sm">Subtotal</div>
            <div className="text-sm">$40.00</div>
          </div>
          <div className="flex justify-between">
            <div className="text-sm">Shipping</div>
            <div className="text-sm">$5.00</div>
          </div>
          <div className="flex justify-between">
            <div className="text-sm">Tax</div>
            <div className="text-sm">$3.00</div>
          </div>

        </div>

        <div className="p-6 flex flex-col gap-6">

          <div className="flex justify-between">
            <div className="text-sm font-semibold">Total</div>
            <div className="text-sm font-semibold">$48.00</div>
          </div>

        </div>

        <div className="p-6 flex flex-col gap-6">

          <button className="bg-indigo-600 text-white rounded-md py-3 px-6">Place order</button>

        </div>

      </div>
    </div>
  );
};

And finally we assemple the checkout page in our clean, updated app/pages/checkout.tsx file

app/pages/checkout.tsx
import { json, LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import CustomerInformation from "~/components/checkout/customer-information";
import PaymentSelection from "~/components/checkout/payment-selection";
import ShippingDetails from "~/components/checkout/shipping-details";
import ShippingSelection from "~/components/checkout/shipping-selection";
import Summary from "~/components/checkout/summary";

export const loader = async ({}: LoaderArgs) => {
  const products = [{
    id: "1",
    name: "Shirt",
    price: "20.00",
    quantity: 1,
    options: ["Black", "Large"]
  },
  {
    id: "2",
    name: "Pants",
    price: "30.00",
    quantity: 1,
    options: ["Blue", "Medium"]
  }];

  return json({
    products
  });
}

export default function CheckoutPage() {
  const { products } = useLoaderData<typeof loader>();

  return (
    <form method="post">
      <div className={"grid md:grid-cols-2 gap-16 mx-auto max-w-screen-xl my-20"}>
        <div id="checkout-information">
          <CustomerInformation />      
          <ShippingDetails />
          <ShippingSelection />
          <PaymentSelection />
        </div>
        <Summary products={products} />
      </div>
    </form>
  );
}

Adding interactivity

However, our site is still quite static. We need to add some interactivity to our checkout page. Remix has us covered with the an action method. It works similar to the loader, but instead of returning data, it returns a response. We can use this to handle form submissions.

Maybe you have noticed that our checkout.tsx contains a <form> element. We can use this to handle the form submission by defining a simple action method.

export const action = async ({ request }: ActionArgs) => {
  const form = await request.formData();
  
  const email = form.get("email");
  
  // Do some validation here
  // Store data, call API etc.

  return redirect("/checkout/success");
};

Using request.formData() we can access the form data. It's that easy. No fetch() or HTTP client needed. It all runs on the server, so there is no need to worry about CORS or anything like that. We will use that action to validate the form data and send it to the Shopware API. But let's start with the basics.

We will add an action method to our checkout.tsx page and perform some validation:

app/pages/checkout.tsx
/* .... */
import ShippingSelection from "~/components/checkout/shipping-selection";
import Summary from "~/components/checkout/summary";

function validateEmail(email: string) {
  if (!email) {
    return "Email is required";
  }

  if(email.length < 5) {
    return "Email must be at least 5 characters long";
  }
}

export const action = async ({ request }: ActionArgs) => {
  const form = await request.formData();
  
  const email = form.get("email");

  if(typeof email !== "string") {
    return json({
      fields: null,
      fieldErrors: null
    }, { status: 400});
  }

  const fieldErrors = {
    email: validateEmail(email)
  };
  const fields = { email }

  if(Object.values(fieldErrors).some(Boolean)) {
    return json({
      fields,
      fieldErrors,
    }, { status: 400 });
  }

  return redirect(".");
};

I defined a simple validateEmail helper method to check for some basic validation rules. We can then use that method in our action method. If the email is invalid, we return a 400 status code and the error message. We also return the fields and fieldErrors so that we can display the error message in the UI. How do we do that? We will use the useActionData hook.

"But wait, I need the validation results in my CustomerInformation component" you might note. No problem - just import the action method in your component and you're done. You can then use the useActionData hook to access the data returned by the action method to display the error message, including typesafety.

app/components/checkout/customer-information.tsx
import { useActionData } from "@remix-run/react";
import { action } from "~/routes/checkout";

export default function CustomerInformation() {
  const actionData = useActionData<typeof action>();

  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";

  return (
    <div>
      <h2 className={headingClasses}>Customer information</h2>
      <label className={labelClasses} htmlFor="email">
        Email Address
        {actionData?.fieldErrors?.email ? (
        <span
          className="ml-5 text-red-600"
          role="alert"
          id="name-error"
        >
          {actionData.fieldErrors.email}
        </span>
      ) : null}
      </label>
      
      <input className={inputClasses} defaultValue={actionData?.fields?.email} type="email" name="email" id="email" />
    </div>
  )
}

Try to enter a mail address that is too short and you will see the error message.

At this point check out your network tab - you will see that we aren't usng a single line of JavaScript. Everything is handled by the server.

The network tab showing the request to the checkout page

If you see some Javascript there, remove the <Scripts /> element from you app/root.tsx file and reload the page. You will see that your app still works. Awesome, right? Let's leave the actions for now, we'll pick up on it later.

Adding state using the API

So far the form is still just a static form, whever we reload the page, the form is reset. So we have to think about how to store state. We will define a loader that fetches all required data from different sources so that we can display it in our form. In a similar fashion, we will define an action that handles the form submission.

The following diagram shows the different areas or forms of our state:

A diagram showing the state areas associated with the checkout form

Loader - fetching data

Let's start with the loader. Our loader has to call various API endpoints:

  • Available shipping methods - GET /store-api/shipping-methods
  • Available payment methods - GET /store-api/payment-methods
  • Available countries - GET /store-api/country
  • Available salutations - GET /store-api/salutation
  • Cart - GET /store-api/checkout/cart

We will create a small helper module that exports our API calls and place it in app/utils/api/checkout.server.tsx. The .server suffix tells the Remix compiler to not add the file to the client bundle.

app/utils/api/checkout.server.tsx
export type ShippingMethod = {
  id: string;
  name: string;
  deliveryTime: {
    name: string
  }
}

export type PaymentMethod = {
  id: string;
  name: string;
  description: string;
}

export type Cart = {
  token: string;
  lineItems: LineItem[];
  price: {
    netPrice: number;
    totalPrice: number;
    positionPrice: number;
  }
}

export type LineItem = {
  id: string;
  label: string;
  quantity: number;
  type: string;
  price: {
    totalPrice: number;
  },
  cover: {
    url: string;
  }
}

export type Country = {
  id: string;
  name: string;
}

export type Context = {
  currency: {
    isoCode: string;
  }
  customer: any;
  paymentMethod: {
    id: string;
  }
  shippingMethod: {
    id: string;
  }
}

// The store-api endpoint of your instance
const API_URL = "https://demo-frontends.shopware.store/store-api";

// Replace with your own sales channel access key
const ACCESS_KEY = "SWSCBHFSNTVMAWNZDNFKSHLAYW";

export const headers = {
  "accept": "application/json, text/plain, */*",
  "sw-access-key": ACCESS_KEY,
  "sw-context-token": "some-token" // This will be taken from the session
}

export async function getShippingMethods (token?: string): Promise<ShippingMethod[]> {
  let res = await fetch(`${API_URL}/shipping-method`, { headers });
  let { elements } = await res.json();

  return elements;
}

export async function getPaymentMethods (token?: string): Promise<PaymentMethod[]> {
  let res = await fetch(`${API_URL}/payment-method`, { headers });
  let { elements } = await res.json();

  return elements;
}

export async function getCart (token?: string): Promise<Cart> {
  let res = await fetch(`${API_URL}/checkout/cart`, { headers });
  let cart = await res.json();

  return cart;
}

export async function getCountries (token?: string): Promise<Country[]> {
  let res = await fetch(`${API_URL}/country`, { headers });
  let { elements } = await res.json();

  return elements;
}

export async function getContext (token?: string): Promise<Context> {
  let res = await fetch(`${API_URL}/context`, { headers });
  let context = await res.json();

  return context;
}

export function formatPrice (price: number, currency: string = 'USD'): string {
  return price.toLocaleString("en-US", {
    style: "currency",
    currency,
  });
}

Now we can create our loader. And this is by far the simplest task. Just run all the API calls in parallel using Promise.all and return the data.

app/routes/checkout.tsx
import {
  getCart,
  getContext,
  getCountries,
  getPaymentMethods,
  getShippingMethods,
} from "~/utils/api/checkout.server";

// Somewhere further down
export const loader = async ({}: LoaderArgs) => {
  const [shippingMethods, paymentMethods, cart, countries, context] = await Promise.all([
    getShippingMethods(),
    getPaymentMethods(),
    getCart(),
    getCountries(),
    getContext()
  ]);

  return json({
    shippingMethods,
    paymentMethods,
    cart,
    countries,
    context
  });
};

Of course this doesn't do anything yet, so we have to refactor some of our components and pass the data as properties. For now I've also removed the action and validation logic in app/routes/checkout.tsx - don't worry, we'll add it back later.

app/routes/checkout.tsx
import { json, LoaderArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import CustomerInformation from "~/components/checkout/customer-information";
import PaymentSelection from "~/components/checkout/payment-selection";
import ShippingDetails from "~/components/checkout/shipping-details";
import ShippingSelection from "~/components/checkout/shipping-selection";
import Summary from "~/components/checkout/summary";
import {
  getCart,
  getContext,
  getCountries,
  getPaymentMethods,
  getShippingMethods,
} from "~/utils/api/checkout.server";

export const loader = async ({}: LoaderArgs) => {
  const [shippingMethods, paymentMethods, cart, countries, context] = await Promise.all([
    getShippingMethods(),
    getPaymentMethods(),
    getCart(),
    getCountries(),
    getContext()
  ]);

  return json({
    shippingMethods,
    paymentMethods,
    cart,
    countries,
    context
  });
};

export default function CheckoutPage() {
  const { cart, countries, paymentMethods, shippingMethods, context } =
    useLoaderData<typeof loader>();

  return (
    <form method="post">
      <div
        className={"grid md:grid-cols-2 gap-16 mx-auto max-w-screen-xl my-20"}
      >
        <div id="checkout-information">
          <CustomerInformation />
          <ShippingDetails countries={countries} />
          <ShippingSelection shippingMethods={shippingMethods} selected={context.shippingMethod.id} />
          <PaymentSelection paymentMethods={paymentMethods} selected={context.paymentMethod.id} />
        </div>
        <Summary cart={cart} />
      </div>
    </form>
  );
}
app/components/checkout/customer-information.tsx
import { useActionData } from "@remix-run/react";
import { action } from "~/routes/checkout";

export default function CustomerInformation() {
  const actionData = useActionData<typeof action>();

  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";

  return (
    <div>
      <h2 className={headingClasses}>Customer information</h2>
      <label className={labelClasses} htmlFor="email">
        Email Address
        {actionData?.fieldErrors?.email ? (
        <span
          className="ml-5 text-red-600"
          role="alert"
          id="name-error"
        >
          {actionData.fieldErrors.email}
        </span>
      ) : null}
      </label>
      
      <input className={inputClasses} defaultValue={actionData?.fields?.email} type="email" name="email" id="email" />
    </div>
  )
}
app/components/checkout/shipping-details.tsx
import { Country } from "~/utils/api/checkout.server";

export default function ShippingDetails({ countries }: { countries: Country[] }) {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const inputClasses = "border border-gray-300 rounded-md p-2 mt-2 w-full";
  const labelClasses = "block text-sm font-medium text-gray-700 mt-6";
  
  return (
    <div className={"mt-10 border-t border-t-slate-300"}>
      <h2 className={headingClasses}>Shipping details</h2>
      <div className={"grid grid-cols-2 gap-4"}>
        <div>
          <label className={labelClasses} htmlFor="first-name">First Name</label>
          <input className={inputClasses} type="text" name="first-name" id="first-name" />
        </div>
        <div>
          <label className={labelClasses} htmlFor="last-name">Last Name</label>
          <input className={inputClasses} type="text" name="last-name" id="last-name" />
        </div>
      </div>
      <div>
        <label className={labelClasses} htmlFor="address">Address Line 1</label>
        <input className={inputClasses} type="text" name="address" id="address" />
      </div>
      <div>
        <label className={labelClasses} htmlFor="address-2">Address Line 2</label>
        <input className={inputClasses} type="text" name="address-2" id="address-2" />
      </div>
      <div className={"grid grid-cols-2 gap-4"}>
        <div>
          <label className={labelClasses} htmlFor="city">City</label>
          <input className={inputClasses} type="text" name="city" id="city" />
        </div>
        <div>
          <label className={labelClasses} htmlFor="state">State</label>
          <input className={inputClasses} type="text" name="state" id="state" />
        </div>
        <div>
          <label className={labelClasses} htmlFor="country">Country</label>
          <select  className={inputClasses} name="country" id="country">
            {countries.map((country) => (
              <option value={country.id} key={country.id}>{country.name}</option>
            ))}
          </select>
        </div>
        <div>
          <label className={labelClasses} htmlFor="zip">Zip</label>
          <input className={inputClasses} type="text" name="zip" id="zip" />
        </div>
      </div>
    </div>
  )
}
app/components/checkout/shipping-selection.tsx
import { ShippingMethod } from "~/utils/api/checkout.server";

export default function ShippingSelection({
  shippingMethods,
  selected,
}: {
  shippingMethods: ShippingMethod[];
  selected: string;
}) {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";
  const deliveryClasses =
    "border border-gray-300 bg-white rounded-md p-5 mt-6 w-full flex justify-between";

  return (
    <div className={"mt-10 border-t border-t-slate-300"}>
      <h2 className={headingClasses}>Delivery method</h2>
      <div className="grid grid-cols-2 gap-4">
        {shippingMethods.map((shippingMethod) => (
          <div className={`${deliveryClasses}`}>
            <div>
              <label
                className="font-bold block mb-1"
                htmlFor={shippingMethod.id}
              >
                {shippingMethod.name}
              </label>
              <div>Delivery in {shippingMethod.deliveryTime.name}</div>
            </div>
            <input
              defaultChecked={selected === shippingMethod.id}
              type="radio"
              name="delivery-method"
              id={shippingMethod.id}
              value={shippingMethod.id}
            />
          </div>
        ))}
      </div>
    </div>
  );
}
app/components/checkout/payment-selection.tsx
import { PaymentMethod } from "~/utils/api/checkout.server";

export default function PaymentSelection({
  paymentMethods,
  selected,
}: {
  paymentMethods: PaymentMethod[];
  selected: string;
}) {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";

  return (
    <div className={"mt-10 border-t border-t-slate-300"}>
      <h2 className={headingClasses}>Payment method</h2>
      <div className="flex gap-10">
        {paymentMethods.map((paymentMethod) => (
          <div className="flex gap-4 mt-4">
            <input
              defaultChecked={selected === paymentMethod.id}
              type="radio"
              name="payment-method"
              id={paymentMethod.id}
              value={paymentMethod.id}
            />
            <label htmlFor={paymentMethod.id}>{paymentMethod.name}</label>
          </div>
        ))}
      </div>
    </div>
  );
}
app/components/checkout/summary.tsx
import SummaryProduct from "~/components/checkout/summary-product";
import { Cart, formatPrice } from "~/utils/api/checkout.server";

export default function Summary({ cart }: { cart: Cart }) {
  const headingClasses = "text-xl font-medium text-gray-900 mt-8";

  return (
    <div id="checkout-summary">
      <h3 className={headingClasses}>Order summary</h3>
      <div className={"border border-gray-300 rounded-md mt-6 w-full bg-white flex flex-col divide-y"}>

        {cart.lineItems.map((product: any) => (
          <SummaryProduct key={product.id} product={product} />
        ))}

        <div className="p-6 flex flex-col gap-6">

          <div className="flex justify-between">
            <div className="text-sm">Subtotal</div>
            <div className="text-sm">{formatPrice(cart.price.netPrice)}</div>
          </div>
          <div className="flex justify-between">
            <div className="text-sm">Shipping</div>
            <div className="text-sm">{formatPrice(cart.price.totalPrice - cart.price.positionPrice)}</div>
          </div>
          <div className="flex justify-between">
            <div className="text-sm">Tax</div>
            <div className="text-sm">{formatPrice(cart.price.totalPrice - cart.price.netPrice)}</div>
          </div>

        </div>

        <div className="p-6 flex flex-col gap-6">

          <div className="flex justify-between">
            <div className="text-sm font-semibold">Total</div>
            <div className="text-sm font-semibold">{formatPrice(cart.price.totalPrice)}</div>
          </div>

        </div>

        <div className="p-6 flex flex-col gap-6">

          <button className="bg-indigo-600 text-white rounded-md py-3 px-6">Place order</button>

        </div>

      </div>
    </div>
  );
};
app/components/checkout/summary-product.tsx
import { formatPrice, LineItem } from "~/utils/api/checkout.server";

export default function SummaryProduct({
  product
}: {product: LineItem}) {
  return (
    <div className="flex gap-6 p-6">
      <div className="w-24 h-32 bg-slate-200 rounded-md overflow-hidden border border-slate-200">
        <img className="h-full object-cover" src={product?.cover?.url} alt={product.label} />
      </div>
      <div className="grow flex justify-between">
        <div className="flex flex-col h-full justify-between">
          <div>
            <div className="">{product.label}</div>
            {/* {product.options.map((option) => (
              <div className="font-light text-slate-600">{option}</div>
            ))} */}
          </div>
          <div>
            <div className="font-semibold">{formatPrice(product.price.totalPrice)}</div>
          </div>
        </div>
        <div className="flex flex-col h-full justify-between">
          <button className="text-sm">Remove</button>
          <div className="flex gap-4">
            <button className="text-sm">-</button>
            <div className="text-sm">{product.quantity}</div>
            <button className="text-sm">+</button>
          </div>
        </div>
      </div>
    </div>
  );
}

Now we've got a form that is hydrated with the data coming from the API. We make sure the default values are set according to the context data. Pretty cool huh?

A screenshot of the checkout form with data from the API

Summary

We've started by designing a simple checkout form and incrementally added more features to it. We organized the markup into multiple components to make it more maintainable. Then we had a look at actions which allow us to perform server requests and validation. No need to manage state, all just using the declarative syntax of HTML forms. Finally we built a small "backend for frontend" to fetch the data from the API and hydrate the form with it.

Make sure to come back soon for the next part of this series where we'll be adding actions to the form and add business logic to the checkout process.

If you want to keep reading in the meantime, check out the Remix docs and their tutorials.