Set up Appcues in a single-page application
Configure page detection for React, Next.js, Vue, Angular, and other SPA frameworks.
Table of Contents
You probably don't need to do anything
If your Appcues installation includes enableURLDetection: true (the default since May 2022), the SDK automatically detects page changes in single-page applications. You don't need to call Appcues.page() manually.
<script type="text/javascript">
window.AppcuesSettings = { enableURLDetection: true };
</script>
<script src="//fast.appcues.com/YOUR_APPCUES_ID.js"></script>
If you installed via NPM, automatic URL detection is enabled by default with Appcues.setup().
To check: Open the Appcues Debugger and navigate between pages in your app. The Tracking Pages indicator should flash briefly and return to green on each navigation. If it does, automatic detection is working and you can stop here.
When to disable automatic detection
enableURLDetection watches the full URL, including query parameters and hash fragments. This causes problems when your app updates query parameters without actually navigating to a different page.
For example, a search page that updates the URL on each keystroke:
https://app.example.com/results?search=t
https://app.example.com/results?search=te
https://app.example.com/results?search=tes
https://app.example.com/results?search=test
The path (/results) hasn't changed, but the URL has. Appcues sees each change as a new page, which interrupts any in-progress Flow and re-checks for content. If your users see Flows disappearing or restarting mid-step, this is likely the cause.
To fix this: Disable automatic detection and call Appcues.page() manually — but only when the pathname actually changes, not on query parameter or hash changes.
Disable it by setting enableURLDetection to false:
<script type="text/javascript">
window.AppcuesSettings = { enableURLDetection: false };
</script>
<script src="//fast.appcues.com/YOUR_APPCUES_ID.js"></script>
Then follow the framework-specific instructions below.
Manual page tracking by framework
Only follow these instructions if you've disabled enableURLDetection. The key principle: call Appcues.page() only when the pathname changes — not when query parameters or hash fragments change. This prevents Flows from restarting when users filter, search, or paginate.
Make sure Appcues.identify() has been called before the first Appcues.page() call. identify() includes a built-in page call, so you don't need to call page() immediately after identifying.
React (with React Router v6+)
Track only pathname changes using useLocation:
import { useLocation } from "react-router-dom";
import { useEffect, useRef } from "react";
function AppcuesPageTracker() {
const location = useLocation();
const prevPathname = useRef(location.pathname);
useEffect(() => {
if (location.pathname !== prevPathname.current) {
prevPathname.current = location.pathname;
window.Appcues?.page();
}
}, [location]);
return null;
}
Add <AppcuesPageTracker /> inside your <BrowserRouter> so it has access to router context:
import { BrowserRouter } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<AppcuesPageTracker />
{/* your routes */}
</BrowserRouter>
);
}
Next.js (App Router)
In Next.js 13+ with the App Router, usePathname from next/navigation already returns only the pathname (no query parameters), so it only fires when the path actually changes:
"use client";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
export function AppcuesPageTracker() {
const pathname = usePathname();
useEffect(() => {
window.Appcues?.page();
}, [pathname]);
return null;
}
Add this component to your root layout (app/layout.tsx or app/layout.js):
import { AppcuesPageTracker } from "./appcues-page-tracker";
export default function RootLayout({ children }) {
return (
<html>
<body>
<AppcuesPageTracker />
{children}
</body>
</html>
);
}
Next.js (Pages Router)
If you're using the legacy Pages Router, listen for route changes in _app.js. The routeChangeComplete callback receives the new URL — compare pathnames to filter out query-only changes:
import { useRouter } from "next/router";
import { useEffect, useRef } from "react";
export default function App({ Component, pageProps }) {
const router = useRouter();
const prevPathname = useRef(router.pathname);
useEffect(() => {
const handleRouteChange = (url) => {
const newPathname = url.split("?")[0];
if (newPathname !== prevPathname.current) {
prevPathname.current = newPathname;
window.Appcues?.page();
}
};
router.events.on("routeChangeComplete", handleRouteChange);
return () => router.events.off("routeChangeComplete", handleRouteChange);
}, [router]);
return <Component {...pageProps} />;
}
Vue 3 (with Vue Router 4)
Use an afterEach navigation guard and compare the path property to ignore query-only changes:
import { createRouter, createWebHistory } from "vue-router";
const router = createRouter({
history: createWebHistory(),
routes: [
// your routes
],
});
router.afterEach((to, from) => {
if (to.path !== from.path) {
window.Appcues?.page();
}
});
export default router;
Angular (v14+)
Subscribe to NavigationEnd events and compare the URL pathname to filter out query parameter changes:
import { Component } from "@angular/core";
import { Router, NavigationEnd } from "@angular/router";
import { filter, pairwise, startWith } from "rxjs/operators";
declare global {
interface Window { Appcues: any; }
}
@Component({
selector: "app-root",
templateUrl: "./app.component.html",
})
export class AppComponent {
constructor(private router: Router) {
this.router.events
.pipe(
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
startWith({ urlAfterRedirects: "" } as NavigationEnd),
pairwise()
)
.subscribe(([prev, curr]) => {
const prevPath = prev.urlAfterRedirects.split("?")[0];
const currPath = curr.urlAfterRedirects.split("?")[0];
if (prevPath !== currPath) {
window.Appcues?.page();
}
});
}
}
Svelte (with SvelteKit)
In your root layout (+layout.svelte), use afterNavigate and compare pathnames:
<script>
import { afterNavigate } from "$app/navigation";
let prevPathname = "";
afterNavigate(({ to }) => {
const newPathname = to?.url?.pathname || "";
if (newPathname !== prevPathname) {
prevPathname = newPathname;
window.Appcues?.page();
}
});
</script>
<slot />
Other frameworks
For any SPA framework not listed above, the pattern is the same: listen for route change events, compare the new pathname against the previous one, and only call window.Appcues.page() if the path actually changed. Skip calls where only query parameters or hash fragments differ.
Confirm it worked
After setting up manual page tracking:
- Open the Appcues Debugger.
- Navigate between several pages in your app.
- Confirm the Tracking Pages indicator turns green after each navigation without a full page refresh.
- To test further, publish a Flow targeted to a specific page URL and restricted to your User ID. Navigate to that page — the Flow should appear without a refresh. If you need to refresh the page for the Flow to show, your
page()call is in the wrong place or firing before the URL updates.
Still stuck?
Collect the following and email support@appcues.com:
- Your Appcues Account ID
- Your SPA framework and router library (with versions)
- The URL pattern where page tracking isn't working
- A screenshot of the Debugger after navigating between pages
- Any console errors related to Appcues