Get client-side routing working with Shopify App Bridge

When you're building an embedded Shopify app using react-router, you want:

  1. Client-side routing (not full-page reloads)
  2. The iframe and top window URL to stay in sync

Shopify App Bridge has several utilities to help us acheve this, but the documentation is a bit spread out, and it can be hard to get everything working.

In this article, I'll outline the things I did to get navigation working smoothly in an embedded Shopify app, which was a single-page app I built using React.

What is Shopify App Bridge?

Shopify App Bridge is a JavaScript library for use with embedded Shopify apps (those designed to load inside the Shopify Admin or Shopify POS).

Because embedded apps are loaded in iframes, Shopify App Bridge can help your app communicate with the main admin application, which is loaded in the top-level window.

It provides actions for common tasks like modifying the title bar or dispatching toasts, and also has React component wrappers for some actions.

1. Client-side routing

There are two pieces to this part of the puzzle: ClientRouter and the AppProvider linkComponent prop. If you're seeing full-page reloads, you're missing one or both of these things.

ClientRouter

If you haven't added ClientRouter yet, the docs explain why you're seeing full-page reloads:

By default, App Bridge applies URL changes from outside your app, such as changes from a navigation item being clicked, by updating the iframe URL. If your app uses client-side routing, such as React Router, then you need to override this behaviour to avoid unnecessary full-page reloads.

ClientRouter prevents App Bridge from changing the iframe URL, and lets you provide a client-side router, like react-router, to handle navigation.

You can use either the useClientRouting hook or the <ClientRouter /> component, and the documentation has examples of how to implement each (note: there was a bug in the React component example at the time I referenced it).

I did get the React component version working, just by placing the ClientRouter component next to my routes like this:

// Routes.jsx
import React from 'react';
import { Switch, Route, withRouter } from 'react-router';
import { ClientRouter } from '@shopify/app-bridge-react';

function Routes(props) {
const { history } = props;

return (
<>
<ClientRouter history={history} />
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/users">
<Users />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</>
);
};

export default withRouter(Routes);

So, this fixes the first problem. But you might still be seeing full-page reloads. Specifically, when you click on any internal links inside your embedded app. That's because they're being loaded as standard <a> tags.

How can we fix this?

AppProvider linkComponent prop

You have likely already included AppProvider from Shopify's Polaris library:

App provider is a required component that enables sharing global settings throughout the hierarchy of your application.

But if you didn't read all the way down the page in the AppProvider docs, you may have missed this important section:

By default Polaris renders <Link> elements (and action objects) as <a> tags. That works well for simple one-page demos. However for more complex multi-page applications that use a router such as react-router you will want links to use the components provided by your router. If you don't then every link will be an <a> tag and thus trigger a whole page refresh instead of navigating client-side.

The linkComponent prop allows you to customize how links behave, by allowing you to pass your router's own Link component.

However, you can't pass the react-router Link component directly; you need a general-purpose component that is capable of identifying and rendering both internal and external links.

You can write a custom wrapper component like this:

// Link.jsx
import React from 'react';
import { Link as ReactRouterLink } from 'react-router';

const IS_EXTERNAL_LINK_REGEX = /^(?:[a-z][a-z\d+.-]*:|\/\/)/;

function Link({ children, url = '', external, ref, ...rest }) {
// react-router only supports links to pages it can handle itself. It does not
// support arbirary links, so anything that is not a path-based link should
// use a reglar old `a` tag
if (external || IS_EXTERNAL_LINK_REGEX.test(url)) {
rest.target = '_blank';
rest.rel = 'noopener noreferrer';
return (
<a href={url} {...rest}>
{children}
</a>
);
}

return (
<ReactRouterLink to={url} {...rest}>
{children}
</ReactRouterLink>
);
}

export default Link;

Then, simply pass it to AppProvider using the linkComponent prop:

<AppProvider linkComponent={Link}>
{/* App content including your <Route> components */}
</AppProvider>

Now, all those internally linking <a> tags should be automatically replaced with react-router-friendly links.

2. Syncing iframe and top window URLs

A common requirement when working with iframes is to keep the URL of the iframe in sync with the top window, and embedded Shopify apps are no different.

It works like this: the app inside the iframe handles its own navigation and routing, which means the iframe URL changes as the user navigates around inside it. We need to communicate these changes to the top window, so it can update its own URL.

RoutePropagator

To achieve this in the context of a Shopify app, we can use RoutePropagator:

RoutePropagator lets you synchronize a Shopify embedded app's URL with the parent page.

Similar to ClientRouter, there is a hook (useRoutePropagation) or a React component (<RoutePropagator />). You can choose either option.

I opted for the React component, and added it next to ClientRouter, so my final routes component looked like this:

// Routes.jsx
import React from 'react';
import { Switch, Route, withRouter } from 'react-router';
import { ClientRouter, RoutePropagator } from '@shopify/app-bridge-react';

function Routes(props) {
const { history, location } = props;

return (
<>
<ClientRouter history={history} />
<RoutePropagator location={location} />
<Switch>
<Route path="/about">
<About />
</Route>
<Route path="/users">
<Users />
</Route>
<Route path="/">
<Home />
</Route>
</Switch>
</>
);
};

export default withRouter(Routes);

With these changes, my app now works without full-page refreshes, the iframe and top window URL stay in sync, and the native browser back/forward buttons work as expected.

Succeed in tech

Get actionable tips on coding, getting hired and working as a developer.

I won't spam you. Unsubscribe any time.