Tutorial: How to embed external content in the app
External content from a third-party provider, such as a live chat, a loyalty page, a review widget, or a guided-selling wizard, is usually delivered as a snippet written for a website. This tutorial explains how to integrate such content into a Shopgate app.
It covers the four available approaches and how to choose between them, followed by the topics that apply to all of them: cookie consent, safe areas, and navigation from the external content back into the app.
Prerequisites
Section titled “Prerequisites”Before you begin this tutorial, complete the following tasks:
- Review the fundamentals of the React JavaScript library.
- Complete the Shopgate Getting Started instructions.
- Read the guide about Portals.
- Create a frontend extension with the Shopgate CONNECT SDK.
Choosing an Approach
Section titled “Choosing an Approach”The approach depends on where the content appears in the app.
| The content is | Approach | Component type |
|---|---|---|
| A widget that floats above the app on every page, for example a chat button | Load the provider SDK once and let it render itself | subscribers |
| A provider page the user navigates to, for example a rewards page | Register a route and load the provider script on it | portals on app.routes |
| A provider web application, for example a configurator | Embed it in an iframe on a route of its own |
portals on app.routes |
| A page with its own session or login | Open it in the In-App Browser | subscribers or a button |
The first three approaches keep the content inside the app. Use the In-App Browser when the content requires its own session, its own login, or cannot be embedded.
Loading a Provider Script on Its Own Route
Section titled “Loading a Provider Script on Its Own Route”Use this approach when the provider supplies a loader script that renders into a container element. The extension registers a route, the route loads the script, and the provider renders the content.
Register the route as a portal on the app.routes target.
{ "id": "@myAwesomeOrganization/rewards", "components": [ { "id": "RewardsPage", "target": "app.routes", "type": "portals", "path": "frontend/routes/Rewards/index.jsx" } ]}The portal component declares the route pattern.
import React from 'react';import { Route } from '@shopgate/engage/components';import Content from './Content';
export const REWARDS_ROUTE_PATTERN = '/rewards';
export default () => ( <Route pattern={REWARDS_ROUTE_PATTERN} component={Content} />);The content component injects the provider script into the document head. The load event of the script element is not sufficient to determine that the provider is ready, because most loader scripts request further resources and define their global object afterwards. Poll for the global object that your code requires, and render a loading indicator until it is available.
import React, { useState, useCallback, useRef } from 'react';import { Helmet } from 'react-helmet';import { View, LoadingIndicator } from '@shopgate/engage/components';import { useThemeComponents } from '@shopgate/engage/core/hooks';// eslint-disable-next-line import/extensionsimport { providerId, instanceId, pageTitle } from '../../config.json';
const SCRIPT_ID = 'rewards-provider-script';
const RewardsContent = () => { const { AppBar } = useThemeComponents(); const [ready, setReady] = useState(false); const pollingRef = useRef(null);
const onScriptLoad = useCallback(() => { const check = () => { if (window?.providerWidgets?.init) { clearInterval(pollingRef.current); setReady(true); } };
pollingRef.current = setInterval(check, 200); check(); }, []);
const onChangeClientState = useCallback(() => { const script = document.getElementById(SCRIPT_ID);
if (script && !ready) { script.addEventListener('load', onScriptLoad); } }, [onScriptLoad, ready]);
return ( <View> <AppBar title={pageTitle} /> <Helmet onChangeClientState={onChangeClientState}> <script async src={`https://cdn.provider.example/loader/${providerId}`} id={SCRIPT_ID} /> </Helmet> {ready ? <div className="provider-widget-instance" data-provider-instance-id={instanceId} /> : <LoadingIndicator />} </View> );};
export default RewardsContent;Identifying the Logged-In User
Section titled “Identifying the Logged-In User”Providers that show user-specific content usually expect a token derived from a secret key. The secret key must not be available in the frontend. Create the token in a backend step that is hooked into the user pipeline, and add it to the pipeline response.
{ "trusted": true, "steps": [ { "path": "extension/hooks/addProviderToken.js", "description": "Injects the provider token into the getUser response", "hooks": ["shopgate.user.getUser.v1:after"], "input": [{ "key": "mail" }], "output": [{ "key": "providerToken", "addPipelineOutput": true }] } ]}const { createHash } = require('crypto')
module.exports = async (context, input) => { const { apiKey } = context.config
if (!input || !input.mail) { return { providerToken: null } }
return { providerToken: createHash('sha256').update(input.mail + apiKey).digest('hex') }}The addPipelineOutput property adds the new field to the pipeline response instead of the step output only. The frontend then reads it from the user state with getUserData. Configure the secret apiKey with "destination": "backend", and all values that the frontend requires with "destination": "frontend".
If the page requires a logged-in user, protect the route instead of handling the logged-out state in the component.
import { appWillInit$ } from '@shopgate/engage/core/streams';import { authRoutes } from '@shopgate/engage/core/collections';import { LOGIN_PATH } from '@shopgate/engage/core/constants';import { REWARDS_ROUTE_PATTERN } from '../routes/Rewards';
export default (subscribe) => { subscribe(appWillInit$, () => { authRoutes.set(REWARDS_ROUTE_PATTERN, LOGIN_PATH); });};Adding an Entry to the Tab Bar
Section titled “Adding an Entry to the Tab Bar”To make the route reachable, add an entry to the tab bar with a portal.
{ "id": "TabBarItem", "target": "tab-bar.cart.after", "type": "portals", "path": "frontend/portals/TabBarItem/index.jsx"}If the feature belongs in the navigation menu instead, use one of the NavMenu portals.
Embedding a Provider Web Application in an iframe
Section titled “Embedding a Provider Web Application in an iframe”Use this approach when the content is a web application that your page cannot render itself. Embed it in an iframe on a dedicated route and communicate with it through postMessage.
Point the iframe at an HTML document that you ship as an extension asset, not at the provider directly. The provider snippet is placed in that document, which keeps it out of the app and provides a single place to translate provider events into messages that the app handles.
import React, { useRef, useEffect, useCallback } from 'react';import { View } from '@shopgate/engage/components';import { useThemeComponents } from '@shopgate/engage/core/hooks';import { useRoute, logger } from '@shopgate/engage/core';// eslint-disable-next-line import/extensionsimport { iFrameURL, customerId } from '../../config.json';
const Wizard = () => { const { AppBar } = useThemeComponents(); const { params: { wizardId } } = useRoute(); const iframeRef = useRef();
const handleMessage = useCallback((event) => { // Only react to messages that came from this iframe. if (iframeRef.current?.contentWindow !== event.source) { return; }
try { const message = JSON.parse(event.data);
switch (message?.type) { case 'window-open': window.open(message.url); break; default: logger.warn('Unhandled message from iframe', message); } } catch (e) { logger.error('Failed to process iframe message', e); } }, []);
useEffect(() => { window.addEventListener('message', handleMessage, false); return () => window.removeEventListener('message', handleMessage); }, [handleMessage]);
return ( <View noKeyboardListener> <AppBar title="Wizard" /> <iframe ref={iframeRef} src={`${iFrameURL}?customerId=${customerId}&wizardId=${wizardId}`} sandbox="allow-scripts allow-same-origin allow-forms allow-popups" title="wizard" /> </View> );};
export default Wizard;Note the following requirements:
-
Compare
event.sourcewith thecontentWindowof theiframe. Every window on the page can send messages, and without this comparison any script can invoke the handler. -
Set the
sandboxattribute and grant only the permissions that the embedded document requires. The list above covers a typical interactive provider application. Removeallow-popupsif the document never opens a popup. -
Add the
cacheprop to the route so that the route remains in the route stack. Without it, navigating from the embedded application to a product page and back restarts the session inside theiframe.<Route pattern={WIZARD_ROUTE_PATTERN} component={Wizard} cache />
The message channel is also used for app behavior that the embedded document cannot perform itself. To hide the tab bar while an input field inside the iframe has focus, for example, dispatch HIDE_TAB_BAR and SHOW_TAB_BAR from the message handler.
Displaying a Widget Across the Whole App
Section titled “Displaying a Widget Across the Whole App”A chat widget is not bound to a page. It renders itself into the document, is displayed above the app, and has to be present on every route. There is no component to place, so the extension consists of a subscriber that initializes the provider SDK once.
import { createMessenger } from '@provider/messenger';import { appDidStart$, routeDidEnter$, logger } from '@shopgate/engage/core';// eslint-disable-next-line import/extensionsimport { widgetKey, pagesWithoutWidget } from './config.json';
export default (subscribe) => { let messenger = null;
subscribe(appDidStart$, async () => { if (!widgetKey) { logger.warn('No widget key configured'); return; }
messenger = await createMessenger({ widgetKey }); });
subscribe(routeDidEnter$, ({ action }) => { if (!messenger) { return; }
const hidden = pagesWithoutWidget.includes(action.route.pattern); messenger.setVisibility({ main: true, button: !hidden }); });};Two adjustments are required in an app. The widget has to be hidden on routes where it would overlap important controls, such as the checkout button in the cart. The routeDidEnter$ subscription handles this, based on a list of route patterns from the extension configuration. In addition, the positioning of the provider has to be corrected, because it is written for a browser window. See Safe Areas and the Tab Bar.
Cookie Consent
Section titled “Cookie Consent”External content usually sets third-party cookies. In most cases it must not be loaded before the user has accepted the corresponding cookie category. Subscribe to the consent stream instead of the app start stream.
import { comfortCookiesAccepted$ } from '@shopgate/engage/tracking/streams';
subscribe(comfortCookiesAccepted$, async () => { messenger = await createMessenger({ widgetKey });});For content that occupies a whole route, render a fallback component instead of the iframe while the consent is missing, so that the page explains the state instead of showing an empty frame. The selector for this decision is getAreComfortCookiesAccepted from @shopgate/engage/tracking/selectors.
For more information about the cookie categories, see the Consent Manager guide.
Safe Areas and the Tab Bar
Section titled “Safe Areas and the Tab Bar”Provider stylesheets position elements relative to the viewport. In the app, the viewport contains an app bar at the top and a tab bar at the bottom, and many devices add insets for the status bar, the notch, and the home indicator. Content that is anchored to bottom: 0 is therefore covered by the tab bar, and a full-screen overlay is displayed below the status bar.
The theme provides the required measurements as CSS variables.
| Variable | Description |
|---|---|
--app-bar-height |
Height of the app bar at the top |
--tabbar-height |
Height of the tab bar at the bottom |
--footer-height |
Height of all elements below the content area |
--safe-area-inset-top |
Inset for the status bar and the notch |
--safe-area-inset-bottom |
Inset for the home indicator |
import { injectGlobal } from '@shopgate/engage/styles';
injectGlobal({ '#provider-chat-button': { bottom: 'max(calc(var(--footer-height) + 16px), max(var(--safe-area-inset-bottom), 16px))', },});Verify the result on a device with a notch and on a tablet.
Navigating Back Into the App
Section titled “Navigating Back Into the App”External content links to the website of the shop, because that is the only address it knows. Following such a link inside the app either leaves the app or opens a web page that duplicates an existing app screen.
Register a redirect for the URL pattern that the external content produces and resolve it to the matching app route.
import { appDidStart$, redirects } from '@shopgate/engage/core';import { fetchSearchResults } from '@shopgate/engage/search';import { getProductRoute } from '@shopgate/engage/product';// eslint-disable-next-line import/extensionsimport { productPagePattern } from '../config.json';
export default (subscribe) => { subscribe(appDidStart$, ({ dispatch }) => { redirects.set(productPagePattern, async ({ action }) => { const { redirectMeta: { pathParams = {} } = {} } = action ?? {}; const searchPhrase = pathParams.productNumber;
if (!searchPhrase) { return null; }
const results = await dispatch(fetchSearchResults({ searchPhrase, limit: 1, resolveCachedProducts: true, }));
const product = results?.products?.[0];
return product?.id ? getProductRoute(product.id) : null; }); });};Add the URL pattern to the extension configuration, because every shop builds its product URLs differently. Returning null leaves the link unchanged, which is the expected result when the target cannot be resolved to an app route.
Configuration
Section titled “Configuration”All values that differ per merchant belong in the configuration block of the extension-config.json. Use "destination": "frontend" for values that the user interface requires, and "destination": "backend" for secrets.
Typical entries are the account or widget ID of the provider, the URL of the embedded document, the route patterns on which a floating widget is hidden, the labels of the tab bar entry, and the URL pattern for the redirect.

