React SDK
@wishsupport/react puts merchant support inside your Shopify app. It provides:
- a support inbox page
- a conversation page
- a floating support widget
Each is built on Polaris web components, with a few custom-styled parts (the launcher, panel and message bubbles) you can theme. It uses the client API for you, with live updates over a WebSocket. Your API key stays on your server.
Before you start
- Your app is a Shopify embedded app with App Bridge v4 and Polaris web components. The current Shopify app template includes both. In development the SDK logs a console warning if either is missing.
- React 18 or later.
- A server that can run a route handler built on the standard
RequestandResponse: React Router, Remix, Next.js route handlers and similar. - A space owner has created an API key under Space → Settings → API keys.
- The owner has set your App slug under Settings → Notifications, so merchant reply emails link back into your app.
1. Install
npm install @wishsupport/react
# server environment, never committed
WISHSUPPORT_API_KEY="<key from your space settings>"
2. Add the server route
The components call this route, and the route calls WishSupport. It adds your key and the shop to every call.
// app/routes/api.wishsupport.$.tsx
import { createSupportHandler } from '@wishsupport/react/server';
import { authenticate } from '../shopify.server';
const handler = createSupportHandler({
apiKey: process.env.WISHSUPPORT_API_KEY!,
identify: async (request) => {
const { session } = await authenticate.admin(request);
const shop = await getShopProfile(session); // your own lookup: name, contact email, plan
return {
shopDomain: session.shop,
shopName: shop.name,
requesterEmail: shop.contactEmail,
plan: shop.planName,
};
},
});
export const loader = handler;
export const action = handler;
On Next.js, put the same handler in app/api/wishsupport/[...path]/route.ts and export it for each method:
import { createSupportHandler } from '@wishsupport/react/server';
const handler = createSupportHandler({
apiKey: process.env.WISHSUPPORT_API_KEY!,
identify: async (request) => {
const shop = await getShopFromSession(request); // your own session check
return { shopDomain: shop.domain, requesterEmail: shop.contactEmail };
},
});
export { handler as GET, handler as POST, handler as PATCH, handler as DELETE };
identify runs for every request the route accepts and tells WishSupport which merchant is asking. Requests for an unknown path, or without the SDK’s X-WishSupport-Client header, are refused before it runs. It returns:
| Field | Required | Description |
|---|---|---|
shopDomain |
Yes | The .myshopify.com domain from your authenticated session. Every read and write is limited to it. |
requesterEmail |
Yes | Where reply alerts for the merchant go, so it must be a real inbox. |
shopName |
No | Shown to your team. Defaults to the shop domain. |
requesterName |
No | The person writing in. Defaults to the shop name. |
plan |
No | The merchant’s plan in your app, recorded when a ticket is opened. |
If identify returns null, throws, or returns an invalid domain or email, the route answers 401 and calls nothing. A thrown Response, such as the one authenticate.admin throws to make App Bridge fetch a fresh session token, goes back to the browser untouched. identify must not read the request body.
createSupportHandler options:
| Option | Default | Description |
|---|---|---|
apiKey |
Your space’s API key. Required. | |
identify |
See above. Required. | |
mountPath |
/api/wishsupport |
Where the route is mounted. It must equal the provider’s endpoint. |
baseUrl |
https://api.wishsupport.app/v1 |
The WishSupport API. |
fetch |
the runtime’s fetch |
The fetch the route uses to call WishSupport. Set it for tests or to route calls through a proxy. |
3. Wrap your app
// app/routes/app.tsx
import { Outlet, useLocation, useNavigate } from 'react-router';
import { WishSupportProvider } from '@wishsupport/react';
export default function App() {
const navigate = useNavigate();
const { pathname } = useLocation();
// keep your existing AppProvider and nav; WishSupportProvider only needs to wrap the pages
return (
<WishSupportProvider navigate={navigate} pathname={pathname}>
<Outlet />
</WishSupportProvider>
);
}
This also adds the floating support widget. Pass launcher={{ show: false }} to hide the floating button. The widget panel still opens from useWishSupport().open().
On Next.js (App Router), the provider needs your router’s hooks, so wrap it in a client component and render that from your root layout:
// app/support-provider.tsx
'use client';
import { usePathname, useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { WishSupportProvider } from '@wishsupport/react';
export function SupportProvider({ children }: { children: ReactNode }) {
const router = useRouter();
const pathname = usePathname();
return (
<WishSupportProvider navigate={router.push} pathname={pathname}>
{children}
</WishSupportProvider>
);
}
Build theme and labels inside this wrapper: a server component cannot pass functions, such as ticketCreated, to a client component. DEFAULT_LABELS and SupportApiError are client exports, so use them only in 'use client' files.
WishSupportProvider props:
| Prop | Default | Description |
|---|---|---|
endpoint |
/api/wishsupport |
Where your server route is mounted. |
basePath |
/app/support |
Where your support pages live. A ticket opens at <basePath>/<code>. |
navigate |
full page load | (path: string) => void. Pass your router’s navigate for in-app transitions. |
launcher |
{ show: true, position: 'bottom-right', offset: 0 } |
show: false hides the button only. position is 'bottom-right' or 'bottom-left'. offset adds pixels to the default 32px gap. |
hideLauncherOn |
[basePath] |
Paths, and everything under them, where the whole widget is hidden. |
pathname |
window.location.pathname |
Pass your router’s location so hideLauncherOn follows client-side navigation. |
theme |
the admin’s own look | primary, launcherGradient: [from, to], radius and fontSize, the last two in pixels. See Look and wording. |
labels |
English | Overrides for any text, including nested tabs, columns and status. See Look and wording. |
live |
true |
Live updates over a WebSocket. false turns them off. |
4. Add the support pages
// app/routes/app.support._index.tsx
import { SupportList } from '@wishsupport/react';
export default function Support() {
return <SupportList />;
}
// app/routes/app.support.$ticketId.tsx
import { useParams } from 'react-router';
import { SupportTicket } from '@wishsupport/react';
export default function SupportConversation() {
return <SupportTicket ticketId={useParams().ticketId!} />;
}
These pages live at /app/support and /app/support/<ticket code>, which is the provider’s default basePath. Pass basePath if you mount them elsewhere.
<SupportList /> props:
| Prop | Default | Description |
|---|---|---|
tabs |
['all', 'active', 'closed', 'archived'] |
Which tabs to show, in order. |
defaultTab |
'active' |
The tab selected first. If it isn’t in tabs, the first entry of tabs is used. |
columns |
['index', 'date', 'code', 'status', 'lastMessage', 'lastReplied'] |
Which columns to show, in order. |
pageSize |
30 |
Tickets per page, from 1 to 100. |
showSearch |
true |
false hides the search field. |
withPage |
true |
false renders the list without its page, to place inside your own. |
heading, breadcrumb |
labels.pageTitle, a “Home” link to /app |
Page header, used with withPage. breadcrumb={null} removes the link. |
onOpenTicket |
navigates to the ticket page | (code: string) => void. |
<SupportTicket /> props:
| Prop | Default | Description |
|---|---|---|
ticketId |
Required. The ticket code, in any letter case. | |
withPage |
true |
false renders the conversation without its page. |
onBack |
navigates to basePath |
Called by the breadcrumb and the not-found button. |
agentName |
labels.agentName (Support Agent) |
Name shown above the conversation. |
Open the widget from your own button
import { useWishSupport } from '@wishsupport/react';
function ContactSupport() {
const { open, unreadCount } = useWishSupport();
return <s-button onClick={open}>Contact support {unreadCount > 0 && `(${unreadCount})`}</s-button>;
}
useWishSupport() must be called under the provider. It returns:
| Field | Description |
|---|---|
open, close |
Open or close the widget panel. |
isOpen |
Whether the panel is open. |
unreadCount |
Open and closed tickets with a reply the merchant hasn’t read. |
liveStatus |
The live connection: 'live', 'connecting', 'reconnecting' or 'off'. It is 'off' with live={false}, after five minutes in a hidden tab, and while redials are paused. The widget’s dot is green only while 'live'. |
It works with launcher={{ show: false }}: the button is hidden, the panel still opens. On the support pages and on hideLauncherOn paths the whole widget is hidden, so open() does nothing there.
Look and wording
import { WishSupportProvider, type DeepPartial, type Labels, type SupportTheme } from '@wishsupport/react';
// Module scope, so each object keeps one identity across renders.
const THEME: SupportTheme = {
primary: '#5533ea',
launcherGradient: ['#7a5cff', '#3d1fbf'],
radius: 12,
fontSize: 13,
};
const LABELS: DeepPartial<Labels> = {
pageTitle: 'Help',
tabs: { active: 'Open' },
ticketCreated: (code) => `Request ${code} sent`,
};
<WishSupportProvider theme={THEME} labels={LABELS}>
themecolours the merchant’s message bubbles, the unread dot and the launcher. Polaris components keep the admin’s own look. Colours must be plain CSS colour values: one containing;, quotes,url()or similar is ignored.labelsrenames any text. Pass only the keys you change. Text with a value in it, liketicketCreated, is a function.DEFAULT_LABELS, exported from the package, lists every key.
How it behaves
- A merchant’s reply to a closed or archived ticket reopens it.
- Opening a conversation marks it read for the merchant.
- Live updates arrive over a WebSocket, and the SDK catches up after a reconnect. It never polls on an interval.
- Each message takes up to 4 files: up to 10 MB each, images up to 5 MB.
- The route retries a rate-limited request (
429,RATE_LIMIT_EXCEEDED) once. It retries aGET,PATCHorDELETEthat fails withINTERNAL_ERRORonce. It never retries aPOSTthat fails withINTERNAL_ERROR, so a ticket or a message is not sent twice. - A failed send keeps the merchant’s draft in the composer, so they can send it again.
Good to know
- Mount
WishSupportProvideronce, at the app root. It owns the live connection, the cache and the widget, and a second provider would add a second of each. - Hoist or memoise a
labelsobject you pass. A new object on every render rebuilds the provider’s context and re-renders every SDK component. - The provider renders its stylesheet inline. To override the
--ws-*variables, use thethemeprop. Rules in your<head>lose to the inline sheet. - The widget is hidden on the support pages and on paths in
hideLauncherOn. It renders on the client only. - The SDK keeps its own TanStack Query client and does not touch yours.
- By default, live updates connect to
wss://api.wishsupport.app. If your app sets aconnect-srccontent security policy, allow that origin. Withlive={false}, data refreshes when the browser tab regains focus. - Every open tab holds a live connection against your key’s shared limits (10 requests a second and 500 connections), so the SDK closes a tab’s connection after five minutes hidden and catches up when it is shown again, spreads out redials after an outage, and pauses after five failed connection attempts until the tab is shown or the network returns.
- The package ships unminified and without source maps.
Security
- The API key is read only by
@wishsupport/react/server. Never pass it to a component, and never expose it through a client-side env prefix such asNEXT_PUBLIC_orVITE_. - The shop always comes from
identify, which reads your authenticated Shopify session. The route ignores any shop or identity a browser sends, so one merchant cannot reach another’s tickets. - The route is not an open proxy. It forwards a fixed list of ticket, message, attachment and live-connection calls, with an allowlist of query parameters, and answers anything else with a
404. - It refuses requests without the SDK’s
X-WishSupport-Clientheader and requires JSON request bodies (multipart for file uploads), which blocks cross-site form posts. An empty mark-read request is exempt from the JSON rule, as it has no body, but not from the header. - Don’t put a permissive CORS layer in front of the route, for example one that allows credentials together with custom headers. That would undo the cross-site protection.
- Attachment URLs must be
https. The route refuses a ticket or message that quotes any other URL; plainhttpis accepted only whenbaseUrlis itselfhttp, for local development.
Troubleshooting
| What you see | Likely cause |
|---|---|
404 with ROUTE_NOT_FOUND |
The provider’s endpoint and the handler’s mountPath differ. |
404 from your framework, not WishSupport JSON |
The route file is not a splat, so the framework answers before the handler runs. Name it api.wishsupport.$.tsx, or put it at app/api/wishsupport/[...path]/route.ts. The SDK reports it as UNKNOWN_ERROR with status 404. |
400 with VALIDATION_ERROR asking for the X-WishSupport-Client header |
Something other than the SDK is calling the route. The SDK sends the header on every request. |
401 with UNAUTHORIZED |
identify returned null, threw, or returned an invalid domain or email. Read the response’s message: WishSupport itself also answers 401 for a missing, invalid or revoked API key. |
500 with HANDLER_MISCONFIGURED |
WISHSUPPORT_API_KEY is not set on the server, or identify read the request body. |
502 with UPSTREAM_UNREACHABLE |
Your server could not reach the WishSupport API. The route logs the reason to your server console. Check baseUrl and the server’s outbound network. |
| The widget does not appear | You are on a support page or a hideLauncherOn path, or launcher.show is false. The widget renders on the client only. |
| Reply emails do not link back into your app | The space owner has not set your App slug under Settings → Notifications. |
| New replies show up only when you refocus the tab or reload | Either live={false}, which refreshes on tab focus by design, or the browser cannot reach the live socket. The socket origin follows the handler’s baseUrl, wss://api.wishsupport.app by default: allow it in your connect-src policy. |
Prefer to build your own UI? Use the Client API quickstart instead.