Proxima is the first official storefront app developed by Swell, built using the Astro web framework. Proxima is open-source, intended to serve as an example for developers to build on, and to make it easy to launch a small-medium catalog online store using a common approach familiar to many merchants. The key feature of Proxima is that it emulates Shopify's Online Store 2.0 framework, allowing you to upload Shopify themes and grow with new functionality offered by Swell.

This guide will walk you through Proxima's routes and features.

Proxima is in active development and open-source on Github. Contributions are welcome.

Proxima has a few specific feature goals that drive its development.

A key reason that merchants choose Swell is for native subscriptions support. In most commerce platforms, subscriptions are an after-thought, poorly integrated and lack customization. We believe it's a primary way for merchants to build a sustainable business and offer the best customer experience possible. While Proxima offers a traditional catalog model, the ability to sell subscription products without 3rd-party add-ons is a big win for merchants.

Support for subscriptions is inherent in Proxima, however it's dependent on themes to implement the display of subscription options and details. Swell's Sunrise theme serves as an example that can be adapted to other existing themes.

Customizing themes can be expensive and time consuming. There are thousands of Shopify themes that merchants have already invested in, and given the Swell's architecture covers most if not all of the same surface areas as Shopify does, we would be able to add a compatibility layer to make adopting Swell much easier. On top of that, a large number of developers are already familiar with the building blocks of these themes, making customization far less costly.

Proxima's compatibility layer covers most Liquid objects, filters, tags, and templates supported by Shopify themes, with the exception of Shopify Apps. As of this writing, the conversion logic is being enhanced and is expected to achieve more complete compatibility levels over time. So far, Proxima has been tested with some of the most popular themes on the market and achieves a high level of compatibility.

Because Proxima is open-source, it's possible to clone it and add custom functionality such as new page routes, integrations, even Liquid tags and filters.

By learning from the patterns in Proxima, you can imagine a whole new set of possibilities in commerce. Proxima itself is likely more complex than most storefront apps would be, given its support for themes and Shopify compatibility, but serves as an example of how far you can take these ideas to help merchants grow.

As with any storefront app supporting Themes, it must render theme templates to display pages. These pages coincide with URL routes and data associated with each template type. The following outlines the individual theme templates supported by Proxima.

The landing page of the storefront.

  • URL: /
  • Template: index.json

List of active categories in a store.

  • URL: /categories
  • Template: categories/index.json
  • Shopify equivalent: list-collections.json
  • Data:
    • categories – Collection of all active categories.

A single category to display its active products. Note: The all category will display all active products.

  • URL: /categories/[slug]
  • Template: categories/category.json
  • Shopify equivalent: collection.json
  • Data:
    • category – The category record.

A page displaying a single product and its details.

  • URL: /products/[slug]
  • Template: products/product.json
  • Shopify equivalent: product.json
  • Data:
    • Product – The product record.

A single content page.

  • URL: /pages/[slug]
  • Template: pages/page.json
  • Shopify equivalent: page.json
  • Data:
    • page – The content page record.

A list of blogs in a category.

  • URL: /blogs/[category]
  • Template: blogs/category.json
  • Shopify equivalent: blog.json
  • Data:
    • category – The blog category record.

A single blog article.

  • URL: /blogs/[category]/[slug]
  • Template: blogs/blog.json
  • Shopify equivalent: article.json
  • Data:
    • blog – The blog content record.

The account login page.

  • URL: /account/login
  • Template: account/login.json
  • Shopify equivalent: customer/login.json

The account signup page.

  • URL: /account/signup
  • Template: account/signup.json
  • Shopify equivalent: customer/register.json

The logged-in account landing page.

  • URL: /account
  • Template: account/index.json
  • Shopify equivalent: customer/account.json

The page a customer is sent to when requesting a password reset link.

  • URL: /account/recover/[key]
  • Template: account/recover.json
  • Shopify equivalent: customer/reset_password.json
  • Data:
    • password_reset_key – Key created from a password recovery request to be submitted with the password reset form.

A page displaying order details.

  • URL: /account/orders/[id]
  • Template: account/order.json
  • Shopify equivalent: customer/order.json
  • Data:
    • order – The customer order record.

A page displaying subscription details.

  • URL: /account/subscriptions/[id]
  • Template: account/subscription.json
  • Shopify equivalent: None
  • Data:
    • subscription – The customer subscription record.

A list of customer addresses to manage.

  • URL: /account/addresses
  • Template: account/addresses.json
  • Shopify equivalent: customer/addresses.json

A list of entries in a custom or app-defined content collection.

  • URL: /content/[collection]
  • Template: content/index.json
  • Shopify equivalent: None
  • Data:
    • model – The content model record.
    • entries – Collection of content records within the model.

A page displaying a single entry of custom or app-defined content collection.

  • URL: /content/[collection]/[slug].json
  • Template: content/entry.json
  • Shopify equivalent: metaobject/[type].json
  • Data:
    • model – The content model record.
    • entry – The content entry record.

There are several standard forms that are rendered by the Liquid form tag. Just like templates, these forms are dynamically converted to Shopify form IDs and parameters when rendering a Shopify theme, otherwise they should be referenced using the parameters outlined here.

Note: Depending on the customer interface, it's common to use JavaScript to post form data to its action URL in order to prevent a page reload.

Render a form for logging into a customer account. This form should be used in the account/login template.

account_login
{% form 'account_login' %}
  <!-- form fields -->
  <input type="email" name="account[email]" />
  <input type="password" name="account[password]" />
{% endform %}
  • Shopify equivalent: customer_login
  • Action URL: /account/login
  • Form fields:
    • account[email]
    • account[password]

Render a form for a customer to signup for a new account. This form should be used in the account/signup template.

account_create
{% form 'account_create' %}
  <!-- form fields -->
  <input type="text" name="account[first_name]" />
  <input type="text" name="account[last_name]" />
  <input type="email" name="account[email]" />
  <input type="password" name="account[password]" />
{% endform %}
  • Shopify equivalent: create_customer
  • Action URL: /account
  • Form fields:
    • account[first_name]
    • laccount[ast_name]
    • account[email]
    • account[password]

Render a form to register a customer without a creating an account password. It will automatically set email_optin: true on the customer record.

account_subscribe
{% form 'account_subscribe' %}
  <!-- form fields -->
  <input type="email" name="account[email]" />
{% endform %}
  • Shopify equivalent: customer
  • Action URL: /account/subscribe
  • Form fields:
    • account[email]

Render a form for a customer to request a password recovery email. This form should be used in the account/login template.

account_password_recover
{% form 'account_password_recover' %}
  <!-- form fields -->
{% endform %}
  • Shopify equivalent: recover_customer_password
  • Action URL: /account/recover
  • Form fields:
    • email

Render a form for a customer reset their account password. This form should be used in the account/reset-password template.

account_password_reset
{% form 'account_password_reset' %}
  <!-- form fields -->
  <input type="password" name="password" />
  <input type="password" name="password_confirmation" />
  <input type="hidden" name="password_reset_key" value="{{ password_reset_key }}" />
{% endform %}
  • Shopify equivalent: reset_customer_password
  • Action URL: /account/recover
  • Form fields:
    • password
    • password_confirmation
    • password_reset_key

Render a form for creating or editing an account address. This form should be used in the account/addresses template.

account_address (new)
{% form 'account_address' %}
  <!-- new address fields -->
  <input type="text" name="address[first_name]" />
  <input type="text" name="address[last_name]" />
  <input type="text" name="address[company]" />
  <input type="text" name="address[address1]" />
  <input type="text" name="address[address2]" />
  <input type="text" name="address[city]" />
  <select name="address[country]"><!-- country options --></select>
  <select name="address[state]" /><!-- state options --></select>
  <input type="text" name="address[zip]" />
  <input type="text" name="address[phone]" />
{% endform %}

To render a form for editing an address, iterate over addresses and pass the account_address_id field.

account_address (edit)
{% for address in account.addresses %}
  <!-- address details -->
  {% form 'account_address' %}
    <!-- edit address fields -->
    <input type="hidden" name="account_address_id" value="{{ address.id }}" />
    <input type="text" name="address[first_name]" value="{{ address.first_name }}" />
    <input type="text" name="address[last_name]" value="{{ address.last_name }}" />
    <input type="text" name="address[company]" value="{{ address.company }}" />
    <input type="text" name="address[address1]" value="{{ address.address1 }}" />
    <input type="text" name="address[address2]" value="{{ address.address2 }}" />
    <input type="text" name="address[city]" value="{{ address.city }}" />
    <select name="address[country]"><!-- country options --></select>
    <select name="address[state]" /><!-- state options --></select>
    <input type="text" name="address[zip]" value="{{ address.zip }}" />
    <input type="text" name="address[phone]" value="{{ address.phone }}" />
  {% endform %}
{% endfor %}
  • Shopify equivalent: customer_address
  • Action URL: /account/addresses
  • Form fields:
    • address[first_name]
    • address[last_name]
    • address[company]
    • address[address1]
    • address[address2]
    • address[city]
    • address[country]
    • address[state]
    • address[zip]
    • address[phone]
    • account_address_id

Render a form to add a product to the cart. This form should be used in the products/product template.

cart_add
{% form 'cart_add' %}
  <!-- form fields -->
  <input type="hidden" name="product_id" value="{{ product.id }}" />
  <input type="hidden" name="variant_id" value="{{ variant.id }}" />
  <input type="number" name="quantity" value="1" />
  <input type="hidden" name="options[{{ option.id }}]" value="{{ option.value }}" />
  <input type="hidden" name="purchase_option" value="{{ plan.id }}" />
{% endform %}
  • Shopify equivalent: product
  • Action URL: /cart/add
  • Form fields:
    • product_id
    • variant_id
    • quantity
    • options
    • purchase_option

Render a form to update the quantity of a cart item.

cart_update

{% for item in cart.items %}
  {% form 'cart_update' %}
    <!-- form fields -->
    <input type="hidden" name="item_id" value="{{ item.id }}" />
    <input type="number" name="quantity" value="{{ item.quantity }}" />
  {% endform %}
{% endfor %}
  • Shopify equivalent: None
  • Action URL: /cart/update
  • Form fields:
    • item_id
    • quantity

Render a form to update update cart item quantities and redirect to the checkout page.

cart_checkout
{% form 'cart_checkout' %}
  {% for item in cart.items %}
    <!-- form fields -->
    <input type="number" name="updates[{{ item.index }}]" value="{{ item.quantity }}" />
  {% endfor %}
{% endform %}
  • Shopify equivalent: product
  • Action URL: /cart/checkout
  • Form fields:
    • updates – Optional array of cart item quantities, in the same order as the current cart item array.

Render a form to change the selected locale and currency. The form should render fields for either locale code or currency code, or both.

localization
{% form 'localization' %}
  <!-- form fields -->
  <select name="locale"><!-- locale options --></select>
  <select name="currency"><!-- currency options --></select>
{% endform %}
  • Shopify equivalent: localization
  • Action URL: /localization
  • Form fields:
    • locale – A locale code.
    • currency – A currency code.

If you plan to fork Proxima, you'll use the Swell CLI to simultaneously run the app in dev mode, and push changes to a Swell store.

First, clone Proxima from Github.

Clone Proxima
git clone https://github.com/swellstores/proxima-app.git

Once cloned, navigate to the directory and install NPM dependencies.

Install dependencies
cd proxima-app/

npm install

In the course of development, you'll run the following command in the top-level directory of the app.

App dev
swell app dev

Behind the scenes, the CLI calls the appropriate npm dev command, for example npx astro dev. In addition, a local proxy is started using ngrok and the Swell backend is notified, providing you with a URL to preview the app locally while being connected to the chosen storefront.

To test Proxima along with changes to a theme such as Sunrise, you'll run the theme in local dev mode using the --local flag, effectively connecting your local theme changes to your local app changes.

App dev with local theme (in another terminal)
swell theme dev --local

In this section, we'll walk you through the core functions and files that make Proxima work. This is not a comprehensive walkthrough, but should give you an idea of how things fit together in the app.

Proxima is currently active development and the following code is likely to change over time.

First, let's look at swell.json which is the primary description of the app and its functionality.

swell.json
{
  "id": "proxima",
  "name": "Proxima",
  "type": "storefront",
  "version": "1.0.68",
  "description": "Official storefront app for Swell",
  "storefront": {
    "theme": {
      "provider": "app",
      "compatibility": {
        "version": "*",
        "conflicts": {
          "exclude_files": [
            "theme/config/**",
            "theme/templates/**",
            "theme/sections/**"
          ]
        }
      },
      "resources": {
        "singletons": {
          "account": "AccountResource",
          "cart": "CartResource"
        },
        "records": {
          "content/blogs": "BlogResource",
          "content/blogs-categories": "BlogCategoryResource",
          "categories": "CategoryResource",
          "pages": "PageResource",
          "products": "ProductResource",
          "search": "SearchResource"
        }
      },
      "forms": [
        {
          "id": "account_login",
          "url": "/account/login"
        },
        {
          "id": "account_create",
          "url": "/account"
        },
        {
          "id": "account_subscribe",
          "url": "/account/subscribe"
        },
        {
          "id": "account_password_recover",
          "url": "/account/recover"
        },
        {
          "id": "account_password_reset",
          "url": "/account/recover"
        },
        {
          "id": "account_address",
          "url": "/account/addresses"
        },
        {
          "id": "cart_add",
          "url": "/cart/add"
        },
        {
          "id": "cart_update",
          "url": "/cart/update"
        },
        {
          "id": "cart_checkout",
          "url": "/cart/checkout"
        },
        {
          "id": "localization",
          "url": "/localization"
        }
      ],
      "pages": [
        {
          "id": "index",
          "label": "Home",
          "url": "/",
          "icon": "home"
        },
        {
          "id": "products/index",
          "label": "Product list",
          "url": "/products",
          "icon": "tag",
          "json": true
        },
        {
          "id": "products/product",
          "label": "Product",
          "url": "/products/:slug",
          "icon": "tag",
          "collection": "products",
          "json": true
        },
        {
          "id": "categories/index",
          "label": "Category list",
          "url": "/categories",
          "icon": "tags",
          "json": true
        },
        {
          "id": "categories/category",
          "label": "Category",
          "url": "/categories/:slug",
          "icon": "tags",
          "collection": "categories",
          "json": true
        },
        {
          "id": "pages/page",
          "label": "Page",
          "url": "/pages/:slug",
          "icon": "page",
          "collection": "content/pages",
          "json": true
        },
        {
          "id": "blogs/blog",
          "label": "Blog post",
          "url": "/blogs/:category.slug/:slug",
          "icon": "rss",
          "collection": "content/blogs",
          "json": true
        },
        {
          "id": "blogs/category",
          "label": "Blog category",
          "url": "/blogs/:slug",
          "icon": "rss",
          "collection": "content/blog-categories",
          "json": true
        },
        {
          "id": "account/index",
          "label": "Customer account",
          "url": "/account",
          "group": "customer",
          "icon": "user",
          "collection": "accounts"
        },
        {
          "id": "account/activate",
          "label": "Customer account activation",
          "url": "/account/activate",
          "group": "customer",
          "icon": "user"
        },
        {
          "id": "account/login",
          "label": "Customer login",
          "url": "/account/login",
          "group": "customer",
          "icon": "user"
        },
        {
          "id": "account/signup",
          "label": "Customer signup",
          "url": "/account/signup",
          "group": "customer",
          "icon": "user"
        },
        {
          "id": "account/recover",
          "label": "Customer password recovery",
          "url": "/account/recover",
          "group": "customer",
          "icon": "user"
        },
        {
          "id": "account/addresses",
          "label": "Customer addresses",
          "url": "/account/addresses",
          "group": "customer",
          "icon": "map-marker",
          "collection": "accounts"
        },
        {
          "id": "account/order",
          "label": "Customer order",
          "url": "/account/orders/:id",
          "group": "customer",
          "icon": "shopping-cart",
          "collection": "orders",
          "json": true
        },
        {
          "id": "account/subscription",
          "label": "Customer subscription",
          "url": "/account/subscriptions/:id",
          "group": "customer",
          "icon": "shopping-cart",
          "collection": "subscriptions",
          "json": true
        },
        {
          "id": "cart",
          "label": "Cart",
          "url": "/cart",
          "group": "checkout",
          "icon": "shopping-cart",
          "collection": "carts"
        },
        {
          "id": "404",
          "label": "404 Page Not Found",
          "url": "/404",
          "group": "other",
          "icon": "page"
        },
        {
          "id": "search",
          "label": "Search",
          "url": "/search",
          "group": "other",
          "icon": "search",
          "json": true
        }
      ],
      "template_collections": {
        "products": "products/product",
        "categories": "categories/category",
        "content/blogs": "blogs/blog",
        "content/pages": "pages/page"
      },
      "cache_hook": "/hooks/theme"
    },
    "menus": [
      {
        "id": "main_menu",
        "name": "Main menu",
        "items": [
          {
            "type": "home",
            "name": "Home"
          },
          {
            "type": "product_list",
            "name": "Shop"
          },
          {
            "type": "page",
            "name": "About Us",
            "value": "about"
          }
        ]
      },
      {
        "id": "footer_menu",
        "name": "Footer menu",
        "items": [
          {
            "type": "page",
            "name": "About Us",
            "value": "about"
          },
          {
            "type": "search",
            "name": "Search"
          },
          {
            "type": "page",
            "name": "Contact",
            "value": "contact"
          },
          {
            "type": "page",
            "name": "Privacy Policy",
            "value": "privacy"
          },
          {
            "type": "page",
            "name": "Terms of Service",
            "value": "terms"
          }
        ]
      }
    ]
  },
  "preview_src": "assets/preview-proxima.jpg",
  "preview_mobile_src": "",
  "use_cases": [
    "direct_to_consumer",
    "wholesale",
    "digital_goods"
  ],
  "purchase_options": [
    "standard",
    "susbcription"
  ],
  "features": [
    "multi_currency",
    "multi_language",
    "bulk_pricing",
    "account_pricing"
  ],
  "highlights": [
    {
      "id": "adapt-brand",
      "title": "Adaptable to your brand",
      "description": "Bring your brand to life with individual custom color sections, adaptable elements, elegant animations, and many more powerful settings.",
      "image_src": "assets/feature-adapt-brand.png"
    },
    {
      "id": "tell-your-story",
      "title": "Tell your story",
      "description": "With more than 21 built-in sections, create unique and creative landing pages to engage your customers and promote your brand identity.",
      "image_src": "assets/feature-tell-your-story.png"
    },
    {
      "id": "cross-selling",
      "title": "Cross-selling capabilities",
      "description": "Designed to help you increase sales through powerful features like product recommendations on product and cart pages and sticky Add to cart.",
      "image_src": "assets/feature-cross-selling.png"
    }
  ]
}

This configures the app in the Swell platform with the following details:

  • ID, name, description, version
  • Type: storefront
  • Storefront configuration
    • Theme provider: app
    • Compatible with all themes indicated for Proxima support
    • Excludes configs, templates, and sections from upgrade conflict checks
  • Theme Editor configurations
    • Map of resource types to class names
    • List of form IDs and their respective routes, used by the Liquid form tag
    • List of page templates
    • Map of collections that support alternate templates
    • Array of default menus, set up when a storefront is created with this app
  • App Store listing details and assets

Next, let's look at shopify_compatibility.json which is used by the Apps SDK to dynamically convert objects and other configurations at runtime.

shopify_compatibility.json
{
  "page_types": {
    "index": "index",
    "products/product": "product",
    "products/index": "collection",
    "categories/category": "collection",
    "categories/index": "list-collections",
    "pages/page": "page",
    "content/entry": "metaobject",
    "blogs/blog": "article",
    "blogs/category": "blog",
    "account/index": "customers/account",
    "account/activate": "customers/activate_account",
    "account/addresses": "customers/addresses",
    "account/login": "customers/login",
    "account/order": "customers/order",
    "account/signup": "customers/register",
    "account/recover": "customers/reset_password",
    "cart": "cart",
    "404": "404",
    "gift-card": "gift_card",
    "search": "search"
  },
  "page_routes": {
    "account_addresses_url": {
      "page_id": "account/addresses"
    },
    "account_login_url": {
      "page_id": "account/login"
    },
    "account_recover_url": {
      "page_id": "account/recover"
    },
    "account_register_url": {
      "page_id": "account/signup"
    },
    "account_url": {
      "page_id": "account/index"
    },
    "all_products_collection_url": {
      "page_id": "products/index"
    },
    "cart_url": {
      "page_id": "cart"
    },
    "collections_url": {
      "page_id": "categories/index"
    },
    "root_url": {
      "page_id": "index"
    },
    "search_url": {
      "page_id": "search"
    },
    "account_logout_url": "/account/logout",
    "cart_add_url": "/cart/add",
    "cart_change_url": "/cart/update",
    "cart_clear_url": "/cart/clear",
    "cart_update_url": "/cart/update",
    "predictive_search_url": "/search/suggest",
    "product_recommendations_url": null
  },
  "page_resources": [
    {
      "page": "collection",
      "resources": [
        {
          "from": "category",
          "to": "collection",
          "object": "ShopifyCollection"
        },
        {
          "from": "product.recommendations",
          "to": "recommendations",
          "object": "ShopifyRecommendations"
        }
      ]
    },
    {
      "page": "list-collections",
      "resources": [
        {
          "from": "categories",
          "to": "collections",
          "object": "ShopifyCollections"
        }
      ]
    },
    {
      "page": "article",
      "resources": [
        {
          "from": "blog",
          "to": "article",
          "object": "ShopifyArticle"
        }
      ]
    },
    {
      "page": "blog",
      "resources": [
        {
          "from": "category",
          "to": "blog",
          "object": "ShopifyBlog"
        }
      ]
    }
  ],
  "object_resources": [
    {
      "from": "AccountResource",
      "object": "ShopifyCustomer"
    },
    {
      "from": "AccountOrderResource",
      "object": "ShopifyOrder"
    },
    {
      "from": "CartResource",
      "object": "ShopifyCart"
    },
    {
      "from": "CategoryResource",
      "object": "ShopifyCollection"
    },
    {
      "from": "PredictiveSearchResource",
      "object": "ShopifyPredictiveSearch"
    },
    {
      "from": "PageResource",
      "object": "ShopifyPage"
    },
    {
      "from": "ProductResource",
      "object": "ShopifyProduct"
    },
    {
      "from": "SearchResource",
      "object": "ShopifySearch"
    },
    {
      "from": "SwellStorefrontPagination",
      "object": "ShopifyPaginate"
    },
    {
      "from": "VariantResource",
      "object": "ShopifyVariant"
    },
    {
      "from": "BlogResource",
      "object": "ShopifyBlog"
    }
  ],

  "forms": [
    {
      "id": "cart_add",
      "shopify_type": "product",
      "client_params": [
        {
          "name": "product_id",
          "value": "{{ product.id }}"
        }
      ]
    },
    {
      "id": "account_password_reset",
      "client_params": [
        {
          "name": "password_reset_key",
          "value": "{{ password_reset_key }}"
        }
      ]
    },
    {
      "id": "account_address",
      "client_params": [
        {
          "name": "account_address_id",
          "value": "{{ address.id }}"
        }
      ]
    },
    {
      "id": "account_login",
      "shopify_type": "customer_login"
    },
    {
      "id": "account_create",
      "shopify_type": "create_customer"
    },
    {
      "id": "account_subscribe",
      "shopify_type": "customer"
    },
    {
      "id": "account_password_recover",
      "shopify_type": "recover_customer_password"
    },
    {
      "id": "account_password_reset",
      "shopify_type": "reset_customer_password"
    }
  ],

  "editor_configs": {
    "checkout_form": "cart",
    "redirect_to_page_start_forms": ["account_password_recover"],
    "script_actions_routes": {
      "cart_add_url": "/cart/add",
      "cart_change_url": "/cart/update",
      "cart_update_url": "/cart/update",
      "cart_url": "/cart"
    },
    "script_routes": {
      "products": "/products",
      "predictive_search_url": "/search/suggest"
    }
  }
}

This file contains the following compatibility configurations:

  • Map of Proxima templates to Shopify page types
  • Map of Shopify Liquid page routes to Proxima page routes
  • Map of individual resource objects to Shopify Liquid objects
  • Map of template-specific resource objects to Shopify Liquid objects
  • Map of Proxima form IDs and their parameters to Shopify forms and parameters
  • Theme Editor specific configurations

The following are groups of functionality within Proxima's Astro app frontend, which utilize the standards of the framework. Requests to the storefront app follow Astro's request flow, which dynamically routes to a pages/ file based on the URL, passing through middleware/ files to apply pre-rendering server-side logic. Finally, we'll describe how certain utilities are used.

The entry point of the storefront is the home page, which routes to the index.astro page.

frontend/src/pages/index.astro
---
import ThemePage from '@/components/ThemePage.astro';
---

<ThemePage id="index" />

This index page primarily uses the ThemePage component to initiate theme data and render the output, simply by passing the template ID.

frontend/src/components/ThemePage.astro
---
import get from 'lodash/get';

import {
  PageError,
  PageNotFound,
  type Swell,
  type SwellTheme,
  type SwellData,
  type ThemeSectionGroup,
} from '@swell/apps-sdk';

import { initSwellTheme } from '@/swell';
import { isTemplateConfig, isPageContentRecord } from '@/utils/types-guard';
import Layout from '@/layouts/Layout.astro';

import ThemeSections from './ThemeSections.astro';
import ErrorPage from './ErrorPage.astro';

interface Props {
  id: string;
  title?: string | ((data: SwellData | undefined) => Promise<string>);
  description?: string | ((data: SwellData | undefined) => Promise<string>);
  required?: string;
  requiredPath?: string;
  content?: string;
  props?: Record<string, any>;
  getData?: (swell: Swell, theme: SwellTheme) => Promise<SwellData> | SwellData;
}

let altTemplate = Astro.url.searchParams.get('view') ?? undefined;
const sections = Astro.url.searchParams.get('sections');
const sectionId = Astro.url.searchParams.get('section_id');
const sectionIds = sections
  ? sections.split(',')
  : sectionId
    ? [sectionId]
    : null;

const { id, title, description, required, content, props, getData } =
  Astro.props;

const { swell, theme } = await initSwellTheme(Astro);

let pageData, pageTitle, pageDescription, pageContent, pageError;

try {
  // Rendering page content
  pageData = getData && (await getData(swell, theme));

  if (required) {
    const isFound = await get(pageData, required);

    if (!isFound) {
      throw new PageNotFound();
    }

    if (typeof altTemplate !== 'string') {
      const path = required.slice(0, required.indexOf('.'));
      altTemplate = await get(pageData, `${path}.theme_template`);
    }
  }

  await theme.initGlobals(id, altTemplate);

  if (pageData) {
    theme.setCompatibilityData(pageData);
  }

  [pageTitle, pageDescription, pageContent] = await Promise.all([
    typeof title === 'string'
      ? await theme.renderTemplateString(title, pageData)
      : typeof title === 'function'
        ? await title(pageData)
        : '',

    typeof description === 'string'
      ? await theme.renderTemplateString(description, pageData)
      : typeof description === 'function'
        ? await description(pageData)
        : '',

    (content ?? sectionIds)
      ? theme.renderAllSections(sectionIds ?? [], {
          ...props,
          ...pageData,
        })
      : theme.renderPage(
          {
            ...props,
            ...pageData,
          },
          altTemplate,
        ),
  ]);

  // TODO: replace with json schema validation
  if (typeof pageContent !== 'string' && !pageContent) {
    throw new PageError('Invalid page content');
  }

  if (isTemplateConfig(pageContent)) {
    if (!pageTitle && pageContent.page?.title) {
      pageTitle = pageContent.page.title;
    }

    if (!pageDescription && pageContent.page?.description) {
      pageDescription = pageContent.page.description;
    }
  }
} catch (err: any) {
  try {
    if (err instanceof PageNotFound) {
      try {
        pageTitle = err.message;
        pageContent = await theme.renderPageTemplate('404');
      } catch (renderErr: any) {
        throw err;
      }
    } else {
      throw err;
    }
  } catch {
    console.error(err);
    pageError = {
      title: err.title,
      status: err.status,
      description: swell.isPreview ? err.description || err.message : undefined,
    };
  }
}

const pageProps = { ...props, ...pageData };

const layoutName = typeof pageContent === 'object'
  ? (pageContent as any)?.layout
  : undefined;

const layoutProps = {
  page_title: pageTitle || pageData?.title || theme.globals.store?.name,
  page_description: pageDescription || pageData?.description,
  content_for_header: theme.getContentForHeader(),
  props,
};
---

{
  pageError ? (
    <ErrorPage {...pageError} />
  ) : sections ? (
    JSON.stringify(pageContent)
  ) : (sectionId && isPageContentRecord(pageContent, sectionId)) ? (
    <Fragment set:html={pageContent[sectionId]} />
  ) : (
    <Layout layout={layoutName} theme={theme} {...layoutProps}>
      <Fragment slot="content_for_layout">
        {isTemplateConfig(pageContent) ? (
          <ThemeSections theme={theme} content={pageContent as ThemeSectionGroup} data={pageProps} />
        ) : theme.isShopify1HomePage(id, pageContent) ? (
          <Fragment set:html={theme.renderShopify1HomePage(pageContent)} />
        ) : (
          <Fragment set:html={pageContent} />
        )}
      </Fragment>
    </Layout>
  )
}

The ThemePage component does the following:

  • Initializes Swell and Theme instances
  • Retrieves page data
  • Initializes theme globals
  • Renders page title, description, and content sections based on the template and page data
  • Renders a 404 page if the template is not found
  • Renders the theme layout with page content as content_for_layout
  • Renders JSON output if sections were requested
  • Renders a section fragment if a section_id was requested

Now let's look at how the layout handles rendering everything together.

frontend/src/layouts/Layout.astro
---
import type { SwellTheme } from '@swell/apps-sdk';

interface Props {
  theme: SwellTheme;
  layout?: string;
  page_title?: string;
  page_description?: string;
  content_for_header?: string;
  props?: {
    [key: string]: any;
  };
}
const { theme, layout, page_title, page_description, content_for_header, props } =
  Astro.props;

const contentForHeader = await Astro.slots.render('content_for_header');
const contentForLayout = await Astro.slots.render('content_for_layout');

const content = await theme.renderLayout(layout || '', {
  page_title,
  page_description,
  content_for_header: `${content_for_header || ''}\n${contentForHeader || ''}`,
  content_for_layout: contentForLayout,
  ...props,
});
---

<Fragment set:html={content} />

The layout receives page content as a fragment prop, then uses theme.renderLayout() to output the final page.

Next, let's look at the page for an individual product.

frontend/src/pages/products/[slug].astro
---
import { Swell } from '@swell/apps-sdk';
import { ProductResource } from '@/resources/product';
import { handleServerRequest } from '@/utils/server';
import ThemePage from '@/components/ThemePage.astro';
import { parseResources } from '@/utils/server-resources';

export async function getData(swell: Swell, slug?: string) {
  const { options, purchase_option, quantity = 1 } = swell.queryParams;

  const product = new ProductResource(swell, slug as string, {
    $recommendations: true,
    $pricing: {
      quantity,
      options,
      purchase_option,
    },
  });

  return {
    product,
  };
}

export const getJSON = handleServerRequest(async ({ swell, context }) => {
  const data = await getData(swell, context.params.slug);
  return parseResources(data);
});
---

<ThemePage
  id="products/product"
  required="product.id"
  title="{{ product.meta_title | default: product.name }}"
  description="{{ product.meta_description | default: product.description }}"
  getData={(swell: Swell) => getData(swell, Astro.params.slug)}
/>

This page retrieves product data using the local ProductResource class and passes it to the template. It passes the $recomendations and $pricing operators to the Swell API in order to adjust pricing based on selected options, and show related products on the page. It also designates how to render the page title and description using basic Liquid syntax.

Finally, you'll notice how it exports a resource method using handleServerRequest() which is then used by [slug].json.ts in order to enable JSON endpoints for product records. All resources data in Proxima is also exposed as JSON endpoints for JavaScript-based themes to enhance the interface in various ways.

Next, let's look at an important endpoint used by the Swell Theme Editor to retrieve storefront app-specific data.

frontend/src/pages/resources/[slug].json.ts
import { handleServerRequest } from '@/utils/server';

import * as resources from '@/resources';

export const GET = handleServerRequest(async ({ swell, params, context }) => {
  const { slug: resource_slug } = context.params as {
    slug: keyof typeof resources;
  };

  const { path, slug = '' } = params;
  const query = JSON.parse(params.query);

  const Resource = resources[resource_slug];

  let resource;

  if (path) {
    resource = await new Resource(swell, slug, query)[path.replace('/', '.')];
    resource = await resource.resolve(false);
  } else {
    resource = await new Resource(swell, slug, query).resolve(false);
  }

  return resource ?? null;
});

This endpoint must exist for an app supporting themes. It takes a slug which represents any one of the app's resource classes and, instantiates and returns it. The handleServerRequest method will convert the data to JSON and render the response.

Theme forms are implemented as Astro middleware, allowing templates to POST data such as account login, add to cart, etc, and redirect to the appropriate page.

First, let's look at the account form implementation.

frontend/src/forms/account.ts
import { SwellTheme, SwellData } from '@swell/apps-sdk';

import type { SwellServerContext } from '@/utils/server';

export async function accountLogin(swellContext: SwellServerContext) {
  const {
    params: { account },
    swell,
    theme,
    context,
  } = swellContext;
  const { redirect } = context;
  const { email, password } = account || {};

  const result = await swell.storefront.account.login(email, password);

  if (result) {
    return redirect('/account', 303);
  }

  await setLoginError(theme);
  return redirect('/account/login', 303);
}

export async function accountCreate({
  swell,
  theme,
  params,
  context,
}: SwellServerContext) {
  const { redirect } = context;
  const { account } = params;

  try {
    const result = await swell.storefront.account.create(account);
    if (result && 'errors' in result) {
      await setCreateAccountErrors(theme, result.errors);
      return redirect('/account/signup', 303);
    }
  } catch (err) {
    console.log(err);
  }

  return redirect('/', 303);
}

export async function accountSubscribe({
  swell,
  theme,
  params,
}: SwellServerContext) {
  const { account } = params;

  try {
    const loggedIn = await swell.storefront.account.get();

    if (loggedIn) {
      // Ignore email if already logged in
      await swell.storefront.account.update({
        email: undefined,
        ...account,
      });
    } else {
      const result = await swell.storefront.account.create(account);
      if (result && 'errors' in result) {
        await setSubscribeAccountErrors(theme, result.errors);
      }
    }
  } catch (err) {
    console.log(err);
  }
}

export async function accountPasswordRecover({
  swell,
  theme,
  params,
  context,
}: SwellServerContext) {
  const { redirect } = context;
  const { email, password_reset_key } = params;

  if (password_reset_key) {
    return accountPasswordReset(arguments[0]);
  }

  // Send recovery email
  try {
    const resetUrl = `${context.request.headers.get('origin')}/account/recover/{password_reset_key}`;
    await swell.storefront.account.recover({
      email,
      password_reset_url: resetUrl,
    });

    theme.setGlobalData({ recover_success: true });
    return;
  } catch (err) {
    console.log(err);
  }

  return redirect('/account/login', 303);
}

export async function accountPasswordReset({
  swell,
  theme,
  params,
  context,
}: SwellServerContext) {
  const { redirect } = context;
  const { password_reset_key, password, password_confirmation } = params;

  if (!password_reset_key) {
    return redirect('/account/login', 303);
  }

  // Submit new password
  try {
    if (
      password_confirmation !== undefined &&
      password !== password_confirmation
    ) {
      await setInvalidPasswordResetConfirmationError(theme);
      return redirect(`/account/recover/${password_reset_key}`, 303);
    } else {
      const result = await swell.storefront.account.recover({
        password_reset_key,
        password,
      });

      if (result && !('errors' in result)) {
        await swell.storefront.account.login(result.email as string, password);
        return redirect('/account', 303);
      }
    }
  } catch (err: any) {
    console.log(err);
    if (err.message.includes('password_reset_key')) {
      await setInvalidResetKeyError(theme);
    } else {
      await setInvalidPasswordResetError(theme);
    }
    return redirect(
      `/account/recover${password_reset_key ? `/${password_reset_key}` : ''}`,
      303,
    );
  }

  return redirect('/account/login', 303);
}

export async function accountAddressCreateUpdate({
  params,
  swell,
  context,
}: SwellServerContext) {
  const { redirect } = context;
  const { update_address_id, delete_address_id, address } = params;

  try {
    if (delete_address_id) {
      await swell.storefront.account.deleteAddress(delete_address_id);
    } else {
      let result;
      if (update_address_id) {
        result = await swell.storefront.account.updateAddress(
          update_address_id,
          address,
        );
      } else {
        result = await swell.storefront.account.createAddress(address);
      }
      if (result && 'errors' in result) {
        console.log(result.errors);
      }
    }
  } catch (err) {
    console.log(err);
  }

  return redirect('/account/addresses', 303);
}

async function setLoginError(theme: SwellTheme) {
  theme.setFormData('account_login', {
    errors: [
      {
        code: 'invalid_credentials',
        field_name: 'email',
        field_label: await theme.lang(
          'forms.account_login.email',
          null,
          'Email',
        ),
        message: await theme.lang(
          'forms.account_login.invalid_credentials',
          null,
          'Invalid email or password',
        ),
      },
    ],
  });
}

export async function setInvalidResetKeyError(theme: SwellTheme) {
  theme.setFormData('account_password_reset', {
    errors: [
      {
        code: 'invalid_reset_key',
        field_name: 'password_reset_key',
        message: await theme.lang(
          'forms.account_recover.invalid_reset_key',
          null,
          'Invalid account recovery key',
        ),
      },
    ],
  });
}

export async function setMissingResetKeyError(theme: SwellTheme) {
  theme.setFormData('account_password_reset', {
    errors: [
      {
        code: 'invalid_reset_key',
        field_name: 'password_reset_key',
        message: await theme.lang(
          'forms.account_recover.invalid_reset_key',
          null,
          'Missing account recovery key',
        ),
      },
    ],
  });
}

async function setInvalidPasswordResetError(theme: SwellTheme) {
  theme.setFormData('account_password_reset', {
    errors: [
      {
        code: 'invalid_password_reset',
        field_name: 'password',
        message: await theme.lang(
          'forms.account_recover.invalid_password_reset',
          null,
          'Invalid password',
        ),
      },
    ],
  });
}

async function setInvalidPasswordResetConfirmationError(theme: SwellTheme) {
  theme.setFormData('account_password_reset', {
    errors: [
      {
        code: 'invalid_password_confirmation',
        field_name: 'password_confirmation',
        message: await theme.lang(
          'forms.account_recover.invalid_password_confirmation',
          null,
          'Password confirmation must match the provided password',
        ),
      },
    ],
  });
}

async function setCreateAccountErrors(theme: SwellTheme, errors: SwellData) {
  if (!errors.email && !errors.password) {
    return;
  }
  theme.setFormData('account_create', {
    errors: [
      ...(errors.email
        ? [
            {
              code: 'invalid_email',
              field_name: 'email',
              field_label: await theme.lang(
                'forms.account_signup.email',
                null,
                'Email',
              ),
              message: await theme.lang(
                'forms.account_signup.invalid_email',
                null,
                'Invalid email address',
              ),
            },
          ]
        : []),
      ...(errors.password
        ? [
            {
              code: 'invalid_password',
              field_name: 'password',
              field_label: await theme.lang(
                'forms.account_signup.password',
                null,
                'Password',
              ),
              message: await theme.lang(
                'forms.account_signup.invalid_password',
                null,
                'Invalid password',
              ),
            },
          ]
        : []),
    ],
  });
}

async function setSubscribeAccountErrors(theme: SwellTheme, errors: SwellData) {
  if (!errors.email || errors.email.code === 'UNIQUE') {
    // Ignore duplicate email error while subscribing
    return;
  }
  theme.setFormData('account_subscribe', {
    errors: [
      {
        code: 'invalid_email',
        field_name: 'email',
        field_label: await theme.lang(
          'forms.account_signup.email',
          null,
          'Email',
        ),
        message: await theme.lang(
          'forms.account_signup.invalid_email',
          null,
          'Invalid email address',
        ),
      },
    ],
  });
}

export default [
  {
    id: 'account_login',
    url: '/account/login',
    handler: accountLogin,
  },
  {
    id: 'account_create',
    url: '/account',
    handler: accountCreate,
  },
  {
    id: 'account_subscribe',
    url: '/account/subscribe',
    handler: accountSubscribe,
  },
  {
    id: 'account_password_recover',
    url: '/account/recover',
    handler: accountPasswordRecover,
    params: [
      {
        name: 'password_reset_key',
        value: '{{ value }}',
      },
    ],
  },
  {
    id: 'account_password_reset',
    url: '/account/recover',
    handler: accountPasswordReset,
  },
  {
    id: 'account_address',
    url: '/account/addresses',
    handler: accountAddressCreateUpdate,
    params: [
      {
        name: 'update_address_id',
        value: '{{ value.id }}',
      },
    ],
  },
];

The middleware expects forms to export an array of id, url, handler, and an optional set of params.

Astro middleware is used to intercept various requests and perform actions using the Swell API, for example to make sure an account is logged in before rendering account pages, otherwise redirecting to the login page.

frontend/src/middleware/account.ts
import { setInvalidResetKeyError } from '@/forms/account';
import { handleMiddlewareRequest, SwellServerContext } from '@/utils/server';

const doLogout = handleMiddlewareRequest(
  'GET',
  '/account/logout',
  async ({ swell, context }: SwellServerContext) => {
    await swell.storefront.account.logout();

    return context.redirect('/', 303);
  },
);

const ensureAccountLoggedIn = handleMiddlewareRequest(
  'GET',
  ['/account', '/account/!(login|signup|recover)'],
  async ({ swell, context }: SwellServerContext) => {
    const loggedIn = await swell.storefront.account.get();

    if (!loggedIn) {
      return context.redirect('/account/login', 303);
    }
  },
);

const validateAccountResetKey = handleMiddlewareRequest(
  'GET',
  '/account/recover{/:password_reset_key}?',
  async ({ swell, theme, params, context }: SwellServerContext) => {
    const { password_reset_key } = params;

    if (password_reset_key) {
      theme.setGlobals({ password_reset_key });

      try {
        const account = await swell.get('/accounts/:last', {
          password_reset_key,
        });

        if (!account) {
          await setInvalidResetKeyError(theme);
        }
      } catch (err) {
        console.log(err);
      }
    } else {
      return context.redirect('/account/login', 303);
    }
  },
);

const deleteAddress = handleMiddlewareRequest(
  'POST',
  '/account/addresses/:delete_address_id',
  async ({ swell, params, context }: SwellServerContext) => {
    const { delete_address_id, _method } = params;

    const isDelete = _method === 'delete' || !_method;

    try {
      if (isDelete && delete_address_id) {
        await swell.storefront.account.deleteAddress(delete_address_id);
      }
    } catch (err) {
      console.log(err);
    }

    return context.redirect('/account/addresses', 303);
  },
);

export default [
  doLogout,
  validateAccountResetKey,
  ensureAccountLoggedIn,
  deleteAddress,
];

Middleware sometimes has functional overlap with form handlers, such as in the cart.ts middleware.

frontend/src/middleware/cart.ts
import { cartGet, cartCheckout } from '@/forms/cart';
import { handleMiddlewareRequest } from '@/utils/server';

const getCart = handleMiddlewareRequest('GET', '/cart', cartGet);
const submitCheckout = handleMiddlewareRequest('POST', '/cart', cartCheckout);

export default [getCart, submitCheckout];

As a storefront app developer, you can decide which endpoints should be used by your frontend, while much of the logic in Proxima is designed to align with Shopify endpoints commonly used by themes.

Proxima contains a few utility methods and classes that are worth learning about.

frontend/src/swell.ts
import {
  Swell,
  SwellTheme,
  SwellAppConfig,
  ShopifyCompatibility,
  CFThemeEnv,
  CFWorkerContext,
  ThemeResources,
  ThemeLookupResourceFactory,
  SwellAppStorefrontThemeResources,
  SwellAppShopifyCompatibilityConfig,
} from '@swell/apps-sdk';
import { AstroGlobal, APIContext, AstroCookieSetOptions } from 'astro';

import forms from '@/forms';
import StorefrontShopifyCompatibility from '@/utils/shopify-compatibility';

import swellConfig from '../../swell.json';
import shopifyCompatibilityConfig from '../../shopify_compatibility.json';
import * as resources from '@/resources';

type ResourceType = typeof resources;
type ResourceKey = keyof ResourceType;

type LookupResourceType = {
  [K in keyof ResourceType]: ResourceType[K] extends ThemeLookupResourceFactory
    ? ResourceType[K]
    : never;
};

type LookupResourceKey = keyof LookupResourceType;

const SWELL_DATA_COOKIE = 'swell-data';

export async function initSwell(
  context: AstroGlobal | APIContext,
  options?: Record<string, any>,
): Promise<Swell> {
  const swell = new Swell({
    url: context.url,
    shopifyCompatibilityConfig:
      shopifyCompatibilityConfig as unknown as SwellAppShopifyCompatibilityConfig,
    config: swellConfig as SwellAppConfig,
    serverHeaders: context.request.headers,
    workerEnv: context.locals.runtime?.env as CFThemeEnv,
    workerCtx: context.locals.runtime?.ctx as CFWorkerContext,
    getCookie(name: string) {
      return getCookie(context, name);
    },
    setCookie(
      name: string,
      value: string,
      options?: AstroCookieSetOptions,
      swell?: Swell,
    ) {
      if (canUpdateCookies(context, swell)) {
        return setCookie(context, name, value, options);
      }
    },
    deleteCookie(name: string, options?: AstroCookieSetOptions, swell?: Swell) {
      if (canUpdateCookies(context, swell)) {
        return deleteCookie(context, name, options);
      }
    },
    ...options,
  });

  return swell;
}

export function canUpdateCookies(
  context: AstroGlobal | APIContext,
  swell?: Swell,
): boolean {
  return !(context as any).response && !swell?.sentResponse;
}

export function getSwellDataCookie(
  context: AstroGlobal | APIContext,
  defaultValue?: object,
) {
  const swellCookie = context.cookies.get(SWELL_DATA_COOKIE)?.value;
  if (!swellCookie) {
    return defaultValue;
  }

  try {
    return JSON.parse(swellCookie);
  } catch {
    // noop
  }

  return defaultValue;
}

const defaultCookieOptions = {
  path: '/',
  samesite: 'lax',
};

export function updateSwellDataCookie(
  context: AstroGlobal | APIContext,
  value: string,
) {
  const swellData = getSwellDataCookie(context, {});
  const valueData = JSON.parse(value) || {};

  context.cookies.set(
    SWELL_DATA_COOKIE,
    { ...swellData, ...valueData },
    defaultCookieOptions,
  );
}

export function getCookie(context: AstroGlobal | APIContext, name: string) {
  const swellCookie = getSwellDataCookie(context);
  return swellCookie?.[name] || undefined;
}

export function setCookie(
  context: AstroGlobal | APIContext,
  name: string,
  value: string,
  options?: AstroCookieSetOptions,
): void {
  const cookieOptions = {
    ...defaultCookieOptions,
    ...options,
  };
  const swellCookie = getSwellDataCookie(context, {});

  swellCookie[name] = value;
  context.cookies.set(
    SWELL_DATA_COOKIE,
    JSON.stringify(swellCookie),
    cookieOptions,
  );
}

export function deleteCookie(
  context: AstroGlobal | APIContext,
  name: string,
  options?: AstroCookieSetOptions,
): void {
  const cookieOptions = {
    path: '/',
    samesite: 'lax',
    ...options,
  };
  const swellCookie = getSwellDataCookie(context, {});

  delete swellCookie[name];
  context.cookies.set(
    SWELL_DATA_COOKIE,
    JSON.stringify(swellCookie),
    cookieOptions,
  );
}

function loadResources<T extends ResourceKey>(resourceList: Record<string, T>) {
  return Object.fromEntries(
    Object.entries(resourceList).map(([key, resource]) => [
      key,
      resources[resource],
    ]),
  );
}

function getResources(
  resourcesConfig: SwellAppStorefrontThemeResources,
): ThemeResources {
  const { singletons, records } = resourcesConfig;

  return {
    singletons: loadResources(singletons as Record<string, ResourceKey>),
    records: loadResources(
      records as Record<string, LookupResourceKey>,
    ) as Record<string, ThemeLookupResourceFactory>,
  };
}

export function initTheme(swell: Swell): SwellTheme {
  return new SwellTheme(swell, {
    forms,
    resources: getResources(swellConfig.storefront.theme.resources),
    shopifyCompatibilityClass:
      StorefrontShopifyCompatibility as unknown as typeof ShopifyCompatibility,
  });
}

export async function initSwellTheme(
  Astro: AstroGlobal | APIContext,
): Promise<{ swell: Swell; theme: SwellTheme }> {
  const swell: Swell = Astro.locals.swell || (await initSwell(Astro));

  // Indicate response was sent to avoid mutating cookies
  if (Astro.locals.swell) {
    swell.sentResponse = true;
  }

  const theme: SwellTheme = Astro.locals.theme || initTheme(swell);

  return { swell, theme };
}

This set of methods deals with initializing the Swell and Theme classes, with configurations that are used by Proxima. The Astro environment on Cloudflare requires a few specific details.

Next, let's look at how Shopify compatibility is configured in Proxima.

frontend/src/utils/shopify-compatibility.ts
import {
  SwellTheme,
  ShopifyCompatibility,
  ShopifyFormResourceMap,
} from '@swell/apps-sdk';

import type { SwellServerContext } from './server';

export default class StorefrontShopifyCompatibility extends ShopifyCompatibility {
  constructor(theme: SwellTheme) {
    super(theme);
  }

  getFormResourceMap(): ShopifyFormResourceMap {
    return [
      {
        type: 'cart_add',
        shopifyType: 'product',
        clientHtml: () => {
          return `
            <input type="hidden" name="product_id" value="{{ product.id }}" />
          `;
        },
        serverParams: async ({ params, theme }: SwellServerContext) => {
          const { id, product_id } = params;
          const prevItems = await theme.globals.cart?.items;

          // Shopify uses id as variant_id, or product_id if no variant selected
          const variant_id = id && id !== product_id ? id : undefined;

          return {
            prevItems,
            variant_id,
          };
        },
        serverResponse: async ({ params, response: cart }: any) => {
          const { prevItems } = params;

          if (cart) {
            // Return last added/updated item where quantity changed
            const cartItems = await cart.items;

            const item = (cartItems || []).find((newItem: any) => {
              const prevItem = (prevItems || []).find(
                (item: any) => item.id === newItem.id,
              );
              return !prevItem || prevItem.quantity !== newItem.quantity;
            });

            return item;
          }
        },
      },
      {
        type: 'cart_update',
        shopifyType: undefined, // No Shopify equivalent, manually executed by the cart_update handler
        serverParams: async ({ params, theme }: SwellServerContext) => {
          const { line, quantity } = params;

          // Convert line number to item_id
          const prevCartItems = await theme.globals.cart?.items;
          const prevItem = prevCartItems?.[line - 1];

          return {
            prevItem,
            item_id: prevItem?.id,
            quantity: Number(quantity),
          };
        },
        serverResponse: async ({ params, response: cart }: any) => {
          const { prevItem, item_id, quantity } = params;

          if (cart) {
            const updatedCartItem = cart.items?.find(
              (item: any) => item.id === item_id,
            );

            // Indicate which item was updated or removed
            return {
              ...cart,
              items_added: prevItem && quantity > 0 ? [updatedCartItem] : [],
              items_removed: prevItem && quantity === 0 ? [prevItem] : [],
            };
          }
        },
      },
      {
        type: 'localization',
        shopifyType: undefined, // Same form type as Shopify
        serverParams: ({ params }: SwellServerContext) => {
          const { country_code, locale_code } = params;

          return {
            currency: country_code,
            locale: locale_code,
          };
        },
      },
      {
        type: 'account_login',
        shopifyType: 'customer_login',
        serverParams: ({ params }: aSwellServerContextny) => {
          const { customer } = params;

          return {
            account: {
              email: customer?.email,
              password: customer?.password,
            },
          };
        },
      },
      {
        type: 'account_create',
        shopifyType: 'create_customer',
        serverParams: ({ params }: SwellServerContext) => {
          const { customer } = params;

          return {
            account: {
              first_name: customer?.first_name,
              last_name: customer?.last_name,
              email: customer?.email,
              password: customer?.password,
            },
          };
        },
      },
      {
        type: 'account_subscribe',
        shopifyType: 'customer',
        serverParams: ({ params }: SwellServerContext) => {
          const { contact } = params;

          return {
            account: {
              email: contact?.email,
              email_optin: true,
            },
          };
        },
      },
      {
        type: 'account_password_recover',
        shopifyType: 'recover_customer_password',
        serverParams: ({ params }: SwellServerContext) => {
          const { customer } = params;

          if (customer) {
            return {
              password: customer.password,
              password_confirmation: customer.password_confirmation,
            };
          }

          return {};
        },
      },
      {
        type: 'account_password_reset',
        shopifyType: 'reset_customer_password',
        clientHtml: () => {
          return `
            <input type="hidden" name="password_reset_key" value="{{ password_reset_key }}" />
          `;
        },
      },
      {
        type: 'account_address',
        shopifyType: 'customer_address',
        clientHtml: (_scope: any, arg: any) => {
          if (arg?.id) {
            return `
              <input type="hidden" name="account_address_id" value="${arg?.id}" />
            `;
          }
        },
        serverParams: ({ params }: SwellServerContext) => {
          const { address } = params;

          const hasName = address?.first_name || address?.last_name;

          return {
            address: {
              first_name: address?.first_name || (!hasName ? 'test' : ''),
              last_name: address?.last_name || (!hasName ? 'test' : ''),
              company: address?.company,
              address1: address?.address1,
              address2: address?.address2,
              city: address?.city,
              country: address?.country,
              state: address?.province,
              zip: address?.zip,
              phone: address?.phone,
            },
          };
        },
      },
    ];
  }
}

Most Shopify conversion is handles by the Apps SDK, but in this case we're implementing our own form handles and parameters and configuring how those map to Shopify form standards.

Finally, there's a few important methods in utils/server.ts that we shouldl look at.

frontend/src/utils/server.ts
import { APIContext, MiddlewareHandler, MiddlewareNext } from 'astro';
import {
  Swell,
  SwellTheme,
  StorefrontResource,
  dehydrateSwellRefsInStorefrontResources,
  SwellData,
} from '@swell/apps-sdk';
import {
  initSwell,
  initTheme,
  getCookie,
  setCookie,
  deleteCookie,
  getSwellDataCookie,
  updateSwellDataCookie,
} from '@/swell';
import { minimatch } from 'minimatch';
import { match } from 'path-to-regexp';
import qs from 'qs';

export interface SwellServerContext {
  params: SwellData;
  swell: Swell;
  theme: SwellTheme;
  context: APIContext;
}

declare global {
  interface Request {
    parsedBody?: Record<string, any>;
    parsedJson?: Record<string, any>;
  }
}

export type SwellServerNext = MiddlewareNext;

function isEditorRequest(context: APIContext): boolean {
  // We can use context.request.headers.get('swell-deployment-mode') === 'editor' when different URLs are used
  const isEditor = Boolean(context.request.headers.get('Swell-Is-Editor'));
  return isEditor && !context.locals.raw;
}

function handleResponse(result: Response, swellContext: SwellServerContext) {
  // return json for editor form actions instead of redirect
  if (isEditorRequest(swellContext.context)) {
    return sendServerResponse(
      {
        isEditor: true,
        redirect: result.headers.get('Location'),
        status: result.status,
      },
      swellContext,
    );
  }

  return result;
}

export function handleServerRequest(
  handler: (
    context: SwellServerContext,
  ) => Promise<Response | string | object> | Response | string | object,
): (
  context: APIContext,
  contextHandler?: (context: SwellServerContext) => void,
) => Promise<Response | undefined> {
  return async (
    context: APIContext,
    contextHandler?: (context: SwellServerContext) => void,
  ) => {
    const serverContext = await initServerContext(context);

    try {
      const result = await handler(serverContext);

      if (result === undefined) {
        return result;
      }

      if (result instanceof Response) {
        return handleResponse(result, serverContext);
      }

      if (contextHandler) {
        contextHandler(serverContext);
      }

      return sendServerResponse(result, serverContext);
    } catch (err: any) {
      return sendServerError(err);
    }
  };
}

export function handleMiddlewareRequest(
  method: string,
  urlParam: string | string[] | ((pathname: string) => boolean),
  handler: (context: SwellServerContext, next: SwellServerNext) => any,
): MiddlewareHandler {
  const matchHandler = getMiddlewareMatcher(urlParam);

  return async (context, next) => {
    if (method !== context.request.method) {
      return next();
    }

    const matchParam = matchHandler(context);
    if (!matchParam) {
      return next();
    }

    const serverContext = await initServerContext(context);

    if (typeof matchParam === 'object') {
      serverContext.params = {
        ...serverContext.params,
        ...matchParam,
      };
    }

    const { theme } = serverContext;

    try {
      const result = await handler(serverContext, next);

      if (result instanceof Response) {
        await preserveThemeRequestData(context, theme);
        return handleResponse(result, serverContext);
      }

      if (result === undefined) {
        return next();
      }

      return sendServerResponse(result, serverContext);
    } catch (err: any) {
      return sendServerError(err);
    }
  };
}

async function initServerContext(
  context: APIContext,
): Promise<SwellServerContext> {
  // use request swell-data if provided
  const swellData = context.request.headers.get('Swell-Data');
  if (swellData) {
    updateSwellDataCookie(context, swellData);
  }
  // use request session if provided. Can be provided without swell-data
  const session = context.request.headers.get('X-Session');
  if (session) {
    setCookie(context, 'swell-session', session);
  }

  const swell: Swell = context.locals.swell || (await initSwell(context));
  context.locals.swell = swell;

  const theme: SwellTheme = context.locals.theme || initTheme(swell);

  if (!context.locals.theme) {
    // Initialize currency and locale
    await theme.swell.getStorefrontSettings();

    context.locals.theme = theme;
  }

  const params =
    context.locals.params ||
    (await getFormParams(context.request, context.url.searchParams));
  context.locals.params = params;

  return {
    params,
    swell,
    theme,
    context,
  };
}

export async function sendServerResponse(
  result: any,
  swellContext: SwellServerContext,
): Promise<Response> {
  const { theme, context } = swellContext;

  let response: any;

  if (theme.shopifyCompatibility) {
    theme.setCompatibilityData(result);
  }

  response = await resolveAsyncResources(result);

  dehydrateSwellRefsInStorefrontResources(response);

  if (typeof response === 'string') {
    response = {
      response,
    };
  } else if (!response) {
    response = {};
  }

  if (isEditorRequest(context)) {
    // set form cookies
    await preserveThemeRequestData(context, theme);
    // return swell-data cookie
    response.swellData = getSwellDataCookie(context);
  }

  return jsonResponse(response);
}

export function sendServerError(err: any) {
  if (!err.code) {
    console.error(err);
  }

  return jsonResponse(
    {
      message: 'Something went wrong',
      description: err.code ? err.message : 'Internal server error',
      errors: err.code && {
        [err.code]: err.message,
      },
      status: err.status || 500,
    },
    {
      status: err.status || 500,
    },
  );
}

export async function getShopifyCompatibleServerParams(
  formType: string,
  swellContext: SwellServerContext,
) {
  const { theme, params } = swellContext;

  let result = params;

  if (theme.shopifyCompatibility) {
    const compatParams =
      await theme.shopifyCompatibility.getAdaptedFormServerParams(
        formType,
        swellContext,
      );
    if (compatParams !== undefined) {
      result = compatParams;
    }
  }

  return result;
}

export async function getShopifyCompatibleServerResponse(
  formType: string,
  swellContext: SwellServerContext,
  response: any,
) {
  const { theme } = swellContext;

  let result = response;

  if (theme.shopifyCompatibility) {
    const compatResponse =
      await theme.shopifyCompatibility.getAdaptedFormServerResponse(formType, {
        ...swellContext.context,
        response,
      });
    if (compatResponse !== undefined) {
      result = compatResponse;
    }
  }

  return result;
}

function getMiddlewareMatcher(
  urlParam: string | string[] | ((pathname: string) => boolean),
): (
  context: APIContext,
) => Partial<Record<string, string | string[]>> | boolean {
  if (typeof urlParam === 'function') {
    return (context: APIContext): boolean => {
      return urlParam(context.url.pathname);
    };
  }

  const urlParamArr = Array.isArray(urlParam) ? urlParam : [urlParam];

  try {
    const urlMatchers = urlParamArr.map((urlMatch) =>
      // Use minimatch for negation support
      urlMatch.includes('!')
        ? (url: string) => minimatch(url, urlMatch)
        : match(urlMatch),
    );

    return (
      context: APIContext,
    ): Partial<Record<string, string | string[]>> | boolean => {
      for (const matcher of urlMatchers) {
        const check = matcher(context.url.pathname);
        if (check) {
          if (typeof check === 'object' && check.params) {
            return check.params;
          }
          return true;
        }
      }
      return false;
    };
  } catch (err: any) {
    console.log(
      `Middleware URL match parameter invalid - ${JSON.stringify(urlParam)} - ${err.toString()}`,
    );
    return () => false;
  }
}

export async function getFormParams(
  request: Request,
  searchParams: URLSearchParams,
): Promise<SwellData> {
  // First parse the query string and then form data
  const params: SwellData = qs.parse(searchParams.toString());

  const requestContentType = request.headers.get('Content-Type') || '';

  // Form data
  if (
    !request.parsedBody &&
    requestContentType.includes('multipart/form-data')
  ) {
    try {
      request.parsedBody = await request.formData();
    } catch {
      // noop
    }
  }

  // JSON data
  if (!request.parsedJson && requestContentType === 'application/json') {
    try {
      request.parsedJson = await request.json();
    } catch {
      // noop
    }
  }

  if (request.parsedBody) {
    // Use qs to parse because form may contain array[] properties
    let formData = '';
    for (const [key, value] of request.parsedBody.entries()) {
      formData += `${key}=${value}&`;
    }

    const formParams = qs.parse(formData);
    for (const key in formParams) {
      params[key] = formParams[key];
    }
  }

  if (request.parsedJson) {
    for (const key in request.parsedJson) {
      params[key] = request.parsedJson[key];
    }
  }

  return params;
}

export function jsonResponse(values: SwellData, options?: ResponseInit) {
  return new Response(JSON.stringify(values), {
    status: 200,
    ...options,
    headers: {
      'Content-Type': 'application/json',
      ...options?.headers,
    },
  });
}

export function restoreThemeRequestData(
  context: APIContext,
  theme: SwellTheme,
) {
  const serializedFormData = getCookie(context, 'swell-form-data');
  if (serializedFormData) {
    try {
      const formData = JSON.parse(serializedFormData);
      for (const [formId, data] of Object.entries(formData)) {
        theme.setFormData(formId, data as SwellData);
      }
    } catch (err) {
      console.log(err);
    }
    deleteCookie(context, 'swell-form-data');
  } else {
    const serializedGlobalData = getCookie(context, 'swell-global-data');
    if (serializedGlobalData) {
      try {
        const globalData = JSON.parse(serializedGlobalData);
        theme.setGlobals(globalData);
      } catch (err) {
        console.log(err);
      }
    }
    deleteCookie(context, 'swell-global-data');
  }
}

export async function preserveThemeRequestData(
  context: APIContext,
  theme: SwellTheme,
) {
  let serializedFormData = theme.serializeFormData();
  if (serializedFormData) {
    serializedFormData = await resolveAsyncResources(serializedFormData);
    setCookie(context, 'swell-form-data', JSON.stringify(serializedFormData));
  } else {
    let serializedGlobalData = theme.serializeGlobalData();
    if (serializedGlobalData) {
      serializedGlobalData = await resolveAsyncResources(serializedGlobalData);
      setCookie(
        context,
        'swell-global-data',
        JSON.stringify(serializedGlobalData),
      );
    }
  }
}

export function wrapSectionContent(
  theme: SwellTheme,
  sectionId: string,
  content: string,
) {
  if (theme.shopifyCompatibility) {
    // TODO: figure out a way to use compatibility class for this
    return `
        <div id="shopify-section-${sectionId}" class="shopify-section">${content}</div>
      `.trim();
  } else {
    return `
        <div id="swell-section-${sectionId}" class="swell-section">${content}</div>
      `.trim();
  }
}

// TODO: replace with util from storefrontjs
export async function resolveAsyncResources(response: any) {
  if (response instanceof StorefrontResource) {
    return await response.resolve();
  }

  if (response instanceof Array) {
    return await Promise.all(
      response.map(async (item: any) => {
        if (item instanceof StorefrontResource) {
          return await item.resolve();
        }
        return item;
      }),
    );
  } else if (typeof response === 'object' && response !== null) {
    for (const [key] of Object.entries(response)) {
      if (response[key] instanceof StorefrontResource) {
        response[key] = await response[key].resolve();
      }
    }
  }

  return response;
}

These methods help simplify the interaction between Proxima middleware and forms, with the Astro request/response flow.

There are many opportunities to learn and customize Proxima for your own needs, so we hope you'll consider forking it and contribute to the possibilities for merchants on Swell.

  • Clone Proxima on Github.
  • Get started building your own storefront app.
  • Take a look at Proxima's official theme, Sunrise.