Adding a “Reorder Just This Item” Button to Shopify Customer Accounts: Cart Permalinks and Navigation Constraints [Tutorial]

Verified in August 2026 with Shopify CLI 3.90 and api_version 2026-04. Where a behavior is specified in the official documentation, we link to it; behaviors that are not documented and were confirmed only on a test store are marked as such.

Introduction: The Built-in Buy Again vs. Reordering a Single Line Item

Shopify's new customer accounts ship with a built-in "Buy again" feature for repurchasing a past order (Help Center). When we tested on a test store whether this built-in feature would cover our requirements, it differed from what we expected in two ways.

Buy again duplicates the entire order. The official order actions documentation defines Reorder as "Add all items from a previous order back to the cart," and the Help Center describes it as "duplicating a past order." In both cases the unit is the order. There is no built-in path for picking one product out of a three-item order and putting just that one back in the cart.

Second, line item properties are not carried over. In a store that saves customer-selected options or input values as line item properties, the cart created by Buy again becomes an order with empty customization data, and the customer proceeds to purchase with the original specifications missing. Both the absence of a per-line-item path and the loss of properties are behaviors we confirmed on a live test store in August 2026; the latter is not mentioned in the official documentation.

The standard order details page. There is a single Buy again button for the whole order, with nothing to click per line item (August 2026, test store)

"I want to reorder just this item, with the same specifications as before" is one of the most common motivations for building custom functionality into customer accounts. It is a question asked repeatedly on the official developer forum, but until now there has been no consolidated answer. This article covers Customer Account UI Extensions (UIE from here on), from a comparison of the available routes to complete, working code.

1. Three Routes from a UI Extension into the Cart

We tried three ways of adding a product to the cart from the UIE sandbox on a test store. We evaluated each on three points: whether the product reaches the cart, whether the behavior is officially documented, and whether the customer lands on the cart page.

Route Official documentation Lands on the cart page Verdict
Storefront API cartCreate Not documented as a way to add to the cart from a UIE No (goes straight to checkout) Rejected
GET request to /cart/add Not in the official documentation Reportedly possible, but unofficial Rejected
Cart permalink Officially documented Yes, with storefront=true Adopted

Storefront API cartCreate: Where the Returned checkoutUrl Goes

UIEs have a capability (api_access) for calling the Storefront API, and the cartCreate mutation can indeed create a cart. However, none of the official material we consulted describes using this mutation from a UIE to add items to the cart.

When we actually tried cartCreate, opening the returned checkoutUrl led to checkout, as specified, and adding return_to did not get us back to a cart page with the product in it. This did not meet the requirement of letting the customer review the cart before proceeding to purchase, so we did not adopt this route.

GET Request to /cart/add: A Route Missing from the Official Documentation

Anyone with theme development experience will think first of calling /cart/add with a GET request. This route is not in the official documentation. Even if it works today, there is no official basis for expecting the same behavior to continue, so we passed on it.

Cart Permalink: The Route with an Official Specification

Of the routes we considered, the cart permalink was the only one that is officially documented and also meets the requirements (official documentation). It works by simply assembling a URL, with no API calls and no authentication. The navigation constraints covered in section 5 also require an href to move to the storefront, so with a cart permalink the product data and the destination can live in the same link.

We also tried reproducing the route the built-in Buy again uses, but Buy again runs on an unexposed internal mechanism called cart_link_id and cannot be reproduced from an app. Nor could we find a cart manipulation API equivalent to applyCartLinesChange in checkout extensions anywhere in the 2026-04 customer-account API list. Similar reports have been raised in the community.

2. The Cart Permalink Specification: properties, storefront=true, and the 25-Property Limit

A cart permalink is a URL of the following form, with the variant ID and quantity in the path and the properties and landing destination in the query string (official documentation).

https://{store domain}/cart/{variantId}:{quantity}?properties={Base64URL-encoded JSON}&storefront=true

Of the query parameters, storefront=true is the official parameter that lands the customer on the cart page instead of sending them straight to checkout, so they can review the added product and its properties in the cart before continuing. properties accepts line item properties as JSON that has been Base64 URL-encoded, but you can specify at most 25 properties.

There are two more constraints stated in the official documentation. Selling plans are not supported, so permalinks cannot be used for subscription products. Permalinks also cannot get past storefront password protection, which trips you up when testing on a store that has not launched yet. Section 4 covers what actually happens.

The variantId in the path is numeric: take the trailing numeric part of the gid://shopify/ProductVariant/... ID returned by the signal. This conversion is included in the final code.

3. Encoding Japanese Properties: UTF-8 via TextEncoder, Then Base64URL

Encoding properties that contain Japanese as-is throws an exception. The browser's built-in btoa() only handles characters in the Latin-1 range, and that is a constraint of the web standard, not of Shopify.

The fix is to convert the string to a UTF-8 byte sequence with TextEncoder first, then encode. With this approach, Japanese properties went through on our test store. The following code wraps that conversion in a function called encodeBase64Url and encodes two properties, a color and a message.

/* Base64 URL encoding that handles non-Latin-1 text such as Japanese */
function encodeBase64Url(value) {
  const bytes = new TextEncoder().encode(value);   // to a UTF-8 byte sequence
  let binary = '';
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');   // make URL-safe and strip padding
}

const properties = {
  '_color': 'Navy',
  '_message': 'Mother\'s Day gift, with noshi wrapping',
};

const encoded = encodeBase64Url(JSON.stringify(properties));

Base64URL is not encryption. Limit the values carried here to what the customer can already see in their own past order, and design on the assumption that nothing confidential travels by this route.

4. Handling Selling Plan (Subscription) Products

The lack of selling plan support is a constraint stated explicitly in the official documentation. Accessing a variant that requires a selling plan through a permalink returns an HTTP 410 "cart error" page. The message we saw on a live store in August 2026 was the following (Japanese storefront; roughly, "Cart error: This variant can only be purchased with a selling plan.").

カートのエラー: バリエーションは販売プランでのみ購入できます。

Because subscription products cannot be added to the cart through a cart permalink, the same reorder button cannot handle them. If you show the button without a check, the customer lands on this error page and goes no further. So determine whether the product has a selling plan, and route subscription products to a separate path, such as the subscription management page.

5. Designing Around the Navigation Constraints

The destination of the reorder button is the storefront cart page, which lies outside the UIE. The navigation methods available inside the account differ from those available outside it, so we verify those constraints first.

Where the Navigation API Can Go, and How to Reach the Storefront

To move to a page within the account, you can use the Navigation API (shopify.navigation.navigate), which can also be called from block targets (official documentation). But destinations are limited to the account itself: the only two protocols available are shopify:customer-account/... (built-in pages) and extension://... (extension routes), and the official documentation states plainly, "It can't redirect to external URLs or the storefront."

The way to reach the storefront (the cart page) is a link's href. If it needs to look like a button, make it a real link with <s-button href="...">. Navigating to the cart from inside an onClick handler cannot be built, because the Navigation API cannot reach the storefront.

Determining the URL When Navigating via href

Since navigation happens via href, the destination URL must be determined by the time the link becomes clickable. In other words, a sequential flow of pressing the button, calling an API, and then jumping to the returned URL cannot be built.

We used two design patterns during verification. One is pre-building: at render time, read the properties from the signal, assemble the permalink, and set it as the href. The other is preparing in the background behind a modal: the first interaction opens a modal, and while the user is reviewing its contents, the URL is assembled in the background and set on a link inside the modal. The latter is for when an external API check has to be inserted.

The per-line-item reorder button uses no external API and can assemble the cart permalink synchronously from the preloaded signal. Because the href can be determined at render time, no backend is needed, and the design stays within the navigation constraints.

6. Customer Account and Storefront Origins: Passing Data Through URL Parameters

When you move a reorder feature from the theme to a UIE, a handoff that relied on sessionStorage can break. The customer account runs on the customer account domain, a different origin from the storefront, and we confirmed on a test store that sessionStorage cannot be shared between them. An implementation built into the theme as "pass the reorder data to the product page through sessionStorage" will not work as-is.

Passing the data statelessly through URL parameters gives the following three-stage flow.

UIE side: set /collections/...?payload={Base64URL(JSON)} as the href
  ↓
Storefront side: a script on the landing page reads the payload and
                 writes it to the same sessionStorage key as before
  ↓
Theme side: the existing restore logic reads from the same key (no changes)

This pattern is for sending customers who want to reselect options before ordering back to the product page. It is separate from the path that puts the same item, unchanged, into the cart through a cart permalink.

As for how much goes into the URL, even a 20-property JSON comes to around 800 characters after Base64 encoding. That fits within URL length limits, so no server-side state is needed. If you match the receiving end to the same storage key as the existing implementation, the theme changes stay confined to one spot on the landing page.

During verification we once put the receiving script in the wrong template. We had guessed from the look of the URL and hit a case where the collection handle and the template suffix crossed. Before placing the receiving script, check the target collection's templateSuffix through the API.

7. The Finished Extension: A Reorder Button per Line Item (Full Code)

The finished product is an extension that shows a "Reorder just this item" button on each line item of the order details page. It reads the properties from the signal, pre-builds the permalink, and sets it as the href. When validation fails, it generates no href and shows no button: a fail-closed design that avoids placing an order with incorrect specifications.

In the config file, set the target to customer-account.order-status.cart-line-item.render-after so that ./src/ReorderButton.jsx is rendered after each line item.

# shopify.extension.toml
api_version = "2026-04"

[[extensions]]
type = "ui_extension"
name = "Reorder line item"
handle = "reorder-line-item"

  [[extensions.targeting]]
  target = "customer-account.order-status.cart-line-item.render-after"
  module = "./src/ReorderButton.jsx"

The main module imports @shopify/ui-extensions/preact and preact's render, and with MAX_PROPERTIES = 25 as the limit, assembles a permalink from the line item's signal and returns either an href or the reason one cannot be produced.

// src/ReorderButton.jsx
import '@shopify/ui-extensions/preact';
import {render} from 'preact';

const MAX_PROPERTIES = 25;

function encodeBase64Url(value) {
  const bytes = new TextEncoder().encode(value);
  let binary = '';
  for (const byte of bytes) {
    binary += String.fromCharCode(byte);
  }
  return btoa(binary)
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/g, '');
}

function numericVariantId(gid) {
  if (typeof gid !== 'string') return null;
  const match = gid.match(/^gid:\/\/shopify\/ProductVariant\/(\d+)$/);
  return match?.[1] ?? null;
}

function collectProperties(attributes) {
  // Empty-valued properties are missing from the signal (observed). Carry over only what arrived
  return (attributes ?? [])
    .filter(({key, value}) => key && value !== '' && value != null)
    .map(({key, value}) => [key, String(value)]);
}

function buildCartPermalink(line, shopUrl) {
  const variantId = numericVariantId(line?.merchandise?.id);
  const entries = collectProperties(line?.attributes);

  if (!variantId || !shopUrl) {
    return {href: null, reason: 'Could not retrieve product information for this line item.'};
  }
  if (entries.length > MAX_PROPERTIES) {
    // Sending only the first 25 would silently drop specifications. Fail closed
    return {href: null, reason: 'This item cannot be reordered because its number of properties exceeds the limit.'};
  }

  const query = [];
  if (entries.length > 0) {
    query.push(`properties=${encodeBase64Url(JSON.stringify(Object.fromEntries(entries)))}`);
  }
  query.push('storefront=true');

  return {
    href: `${shopUrl}/cart/${variantId}:1?${query.join('&')}`,
    reason: null,
  };
}

function ReorderButton() {
  const line = shopify.target.value;               // this line item (signal)
  const shopUrl = shopify.shop.storefrontUrl;      // absolute storefront URL (Shop API, not a signal)
  const {href, reason} = buildCartPermalink(line, shopUrl);

  if (!href) {
    return <s-text>{reason}</s-text>;
  }

  return <s-button href={href}>Reorder just this item</s-button>;
}

export default async () => {
  render(<ReorderButton />, document.body);
};

We confirmed during verification that empty-valued properties are missing from the signal, so collectProperties carries over only the values that arrived.

There are two branches that produce no href. When the variant ID or the storefront URL cannot be obtained, it shows Could not retrieve product information for this line item. When there are more than 25 properties, it shows This item cannot be reordered because its number of properties exceeds the limit. Sending only the first 25 would drop specifications without the customer noticing, so it generates no href and displays only the reason.

When the URL can be assembled, it shows a Reorder just this item button with ${shopUrl}/cart/${variantId}:1?properties=...&storefront=true set as its href. properties is omitted when there are zero entries, and storefront=true is always appended.

The URL is built as an absolute storefront URL. Because the UIE runs on the customer account domain, a different origin from the storefront, a relative path like /cart/... risks resolving to an unintended destination. The absolute URL comes from shopify.shop.storefrontUrl, which is a Shop API value, not a signal. The line item, on the other hand, is read from the shopify.target.value signal.

The quantity is fixed at 1. To match the original quantity, use line.quantity; whether to reorder the same quantity or one unit at a time depends on what you sell.

This code does not check inventory or availability. If the variant has been discontinued, the customer is taken to the cart error page, so check availability before showing the button if needed. If you add such a check, do not show the button when the result cannot be obtained either. Implementations that involve external requests are covered in Calling External APIs from Customer Account UI Extensions: network_access, CORS, and Testing with Real Data.

The place to verify behavior is the live cart on the store, not the build output. Test six cases separately: properties containing Japanese, multiple quantities, no properties, exactly 25 properties, more than 25 properties, and a selling plan product. A successful build does not guarantee that the landing page and the cart contents are correct.

What to Read Next

Once you want to use information held by an external system in a check, you will be calling an external API directly from the UIE. Calling External APIs from Customer Account UI Extensions: network_access, CORS, and Testing with Real Data covers what actually happens with CORS in that case and how to test the whole feature with real data. Adding such a check to the finished extension presupposes both external API calls and verification with real data.

WRITTEN BY

Kitsune

Shopify Developer

I work across design and engineering, building Shopify stores and providing technical support. I have been building e-commerce on Shopify since around 2019 and have worked with more than 100 stores to date. What matters to me is giving shape, through design and development, to the specific requirements that existing apps alone can't reach. I share what I learn on the job.

Talk to us
BACK TO TECH INSIGHTS