Agent Compatibility in Single-Page Apps
Single-page apps built with React, Vue, Svelte or Angular change what is on screen without loading a new page. Done well, that feels fast. Done carelessly, links are really scripted clicks, views have no address of their own, the back button forgets where you were and every missing page returns "200 OK".
People cope; AI agents often do not, because they rely on links, addresses and page changes they can detect. This guide covers the routing and state problems specific to single-page apps, with framework-specific fixes, and points to the related guides for rendering, forms and menus.
Why single-page apps trip agents up
In a traditional site every click on a link asks the server for a new page. The address changes, the browser shows a new title, the back button works and the server can say "this page does not exist" with a 404. Agents are built around those signals.
A single-page app replaces all of that with JavaScript. The router listens for clicks, swaps components and, if the developers remembered, updates the address with the History API. Each of those steps is optional, and each one that is skipped removes a signal an agent uses:
- No real link. A
divorbuttonwith a click handler that callsnavigate()has no address. An agent that reads links from the page does not know it leads anywhere, and cannot open it in a new tab to check. - No address for the view. The product, the step of a booking or the filtered list exists only in memory. If the agent reloads, shares the link with its user or comes back later, it lands on the home page.
- No visible page change. The title stays the same and focus stays on the old button, so an agent reading the accessibility tree may not notice that anything happened.
- No honest status code. The server returns the same HTML shell with 200 for every address, including ones that do not exist, so the agent cannot tell a missing product from a slow one.
- Nothing to read before scripts run. Without server rendering, the first HTML is an empty shell. That problem is covered in fixing JavaScript-only content; this guide deals with what happens after.
A ten-minute check of your app
Run the AI Agent Compatibility audit on a few addresses inside the app, not just the home page, then try these checks by hand.
- Right-click the main navigation items, product tiles and "next" buttons. If the browser does not offer "Open link in new tab" or "Copy link address", they are not links.
- Copy the address of a product, a filtered list and step two of a booking, then open each in a new private window. Every one should show the same view, not the home page, a blank screen or a server error.
- Use the back and forward buttons after changing a filter or tab. They should step through the states you visited.
- Watch the browser tab title as you move around. It should change for each view.
- Request an address that does not exist:
A result of 200 means the server is claiming the page exists.curl -s -o /dev/null -w "%{http_code}\n" https://app.example.com/products/does-not-exist - Tab through the page with the keyboard only. Controls you cannot reach or operate this way will also trouble agents; the React accessibility guide goes deeper on this.
The fix, step by step
Step 1
Turn scripted clicks into real links
Anything that takes the user to another view should be an anchor with an href.
Use the router's own link component, such as Link in React Router and Next.js, RouterLink
in Vue, routerLink in Angular or a plain anchor in SvelteKit, because these render a real
<a href> while still switching views without a reload. Keep button elements
for actions that change something on the current view, such as adding to a basket.
Step 2
Make every view load from its own address
Use path-based routes such as /products/fell-walker rather than hash routes
such as /#/products/1, and give every view someone might want to return to a route of its
own. Configure the web server or host to serve the app for any unknown path so deep links work on a fresh
load. Then open each route type directly in a private window to confirm it shows the right content.
Step 3
Keep filters, tabs and steps in the URL
Store anything that changes what the user is looking at in the address rather than only in component state: search terms, filters, sort order, page number, selected variant, open tab and the current step of a multi-step flow. Use query parameters or path segments, update them with the router so the back button works, and read them on load so the same view appears when the link is opened elsewhere.
Step 4
Update the title and move focus on each route change
Give each route a descriptive document.title, using the framework's
head or metadata feature so it is also rendered on the server. After a route change, move keyboard focus
to the new view's main heading or main element, and consider a polite live region that announces
the new page. That tells agents reading the accessibility tree, and screen reader users, that the page
has changed.
Step 5
Return real status codes for missing routes
When the server or rendering layer knows a route or record does not exist, send a 404 status
with a helpful not-found page instead of the normal shell with 200. With server rendering this is usually
a framework function such as notFound() in Next.js or throwing an error with statusCode: 404
in Nuxt. For purely static hosting, return a 404 page for unknown paths outside the known route patterns.
Step 6
Render routes on the server
Switch on the server rendering or static generation your framework provides, so every route arrives as complete HTML and then hydrates. Make sure links and forms work as plain HTML before hydration finishes, because an agent may click before your scripts are ready. If a full move to server rendering is not possible yet, prioritise the public routes an agent is most likely to use: products, services, bookings and contact.
Step 7
Test the journey from a cold start
Walk through your most valuable journey, such as search, product, basket and checkout, using only the keyboard and with each step opened from a pasted address in a fresh private window. Check the title, focus, back button and status codes at each stage. Run the AI Agent Compatibility audit on several deep routes, and give an agent you have access to the same task to see where it hesitates.
Code patterns that make the difference
Links, not click handlers
// Hard for agents: no address, not a link
<div className="tile" onClick={() => navigate(`/products/${p.slug}`)}>
{p.name}
</div>
// Works for everyone: a real anchor with client-side routing
import { Link } from 'react-router';
<Link className="tile" to={`/products/${p.slug}`}>{p.name}</Link>
In Vue the equivalent is <RouterLink :to="`/products/${p.slug}`">, in Angular <a [routerLink]="['/products', p.slug]">,
and in Next.js <Link href={`/products/${p.slug}`}> from next/link.
Deep links on the server
For a client-rendered app served by nginx, this sends real files when they exist and the app's shell for everything else, so deep links load:
location / {
try_files $uri $uri/ /index.html;
}
That rule returns 200 for every unknown address, which is exactly the soft 404 problem. It is acceptable
only as a stopgap while you move to server rendering, where the app can decide the status for each request.
On Netlify the equivalent is a _redirects rule of /* /index.html 200, with
the same caveat.
Filters in the address
import { useSearchParams } from 'react-router';
function Filters() {
const [params, setParams] = useSearchParams();
const size = params.get('size') ?? '';
return (
<label>Size
<select value={size}
onChange={e => setParams(p => { p.set('size', e.target.value); return p; })}>
<option value="">Any</option>
<option value="9">UK 9</option>
<option value="10">UK 10</option>
</select>
</label>
);
}
Choosing a size now produces an address such as /boots?size=9 that can be reloaded, shared and stepped back through.
Focus after navigation
// After each route change, move focus to the new view's heading
useEffect(() => {
document.querySelector('main h1')?.focus();
}, [location.pathname]);
// The h1 needs tabIndex={-1} so it can receive focus without joining the tab order
Framework by framework
| Framework | Server rendering | Things to check |
|---|---|---|
| Next.js (App Router) | Server Components render on the server by default | Pages
that fetch their main data in a client component with useEffect; metadata set per route
with generateMetadata; notFound() for missing records. |
| Next.js (Pages Router) | Via getServerSideProps or getStaticProps | Pages
with neither, which ship an empty shell for their data; notFound: true for missing records. |
| Nuxt | On by default | That ssr: false has not been set in nuxt.config;
data loaded with useFetch or useAsyncData rather than only in onMounted. |
| SvelteKit | On by default | Routes with export const ssr = false; data
in load functions; error(404) for missing records. |
| Angular | Available through Angular's server-side rendering package | Apps still
built as client-only; routerLink used on anchors rather than click handlers on other elements;
Title service set per route. |
| React with Vite or the older Create React App | None by default | Consider moving public routes to a framework with server rendering, such as React Router's framework mode or Next.js. Create React App is no longer maintained. |
Framework defaults change between major versions, so check the documentation for the version you run rather than relying on a blog post from two years ago.
Edge cases in app-style sites
- Modals that act as pages. A product quick-view or a sign-up dialog with no address cannot be linked to. For anything with meaningful content, give it a route, even if it opens as an overlay.
- Infinite scroll. Lists that load more as you scroll hide everything beyond the first
batch from agents that do not scroll. Add paginated addresses such as
?page=3with real links, and keep infinite scroll as an enhancement on top. - Hydration gaps. Server-rendered buttons that do nothing until the app hydrates look clickable to an agent. Use real forms and links that work before hydration, or keep the time to hydration short.
- Service workers. An old service worker can keep serving a cached shell after you deploy server rendering. Version your service worker and test in a fresh profile.
- Signed-in areas. Agents act for a real person and will hand over for sign-in. Make sure the return address after sign-in brings them back to the page they were on, not the dashboard.
1. Can AI agents use single-page apps at all?
Yes. Agents that drive a real browser run JavaScript, so a well-built single-page app can work perfectly well for them. The trouble comes from shortcuts: navigation that is not made of real links, views without their own address, titles that never change and every page returning 200. Fix those and render public routes on the server, and a single-page app can be as easy for an agent to use as a traditional site.
2. Is hash routing bad for agents?
Hash routes such as /#/products/12 do work in a browser, but everything after the hash is never sent to the server. That means the server cannot render the right content or return a correct status code, and tools that fetch the address get the home page shell. Path-based routes such as /products/fell-walker, supported by every modern router, let the server deliver the right page. Switch when you can, and redirect the old hash addresses in the app so existing bookmarks and shared links keep working.
3. Why does my app show the home page when I paste a deep link?
Usually because the web server does not know about the app's routes. It looks for a file called products/fell-walker, finds none and returns an error or a default page. Configure the host to serve the app for unknown paths, for example with try_files in nginx or a rewrite rule on Netlify or Vercel. Better still, use server rendering so each route is generated with the right content and status code.
4. Do I need server-side rendering for agent compatibility?
For public pages that customers or agents need to use, it is the most reliable approach, because the first HTML then contains the content and the links. It is not the only requirement, though. A server-rendered app can still have scripted clicks instead of links, filters kept only in memory and missing focus management. Treat server rendering as the foundation and the routing fixes in this guide as the rest of the job.
5. How should a single-page app handle 404 pages?
It should send a real 404 status when a route or record does not exist, together with a friendly page offering search or links back into the site. With server rendering, use your framework's not-found helper, such as notFound in Next.js or error(404) in SvelteKit. In a purely client-side app the server cannot know, so it returns 200 for everything, which is one of the strongest reasons to move public routes to server rendering.
6. Will moving state into the URL make addresses ugly?
A little, but the trade is worth it. An address such as /boots?size=9&sort=price is readable and tells people and agents exactly what they are looking at. Keep parameter names short and meaningful, leave out defaults so the base address stays clean, and keep the order consistent. For search engines, point canonical links from filtered views to the main list where the filtered view is not worth indexing separately, so the extra addresses help visitors without creating duplicate pages in search.
7. How do I test my app the way an agent would use it?
Start from a cold start: paste a deep address into a fresh private window, then complete your main journey using only the keyboard, checking titles, focus and the back button as you go. Repeat with JavaScript disabled to see what arrives before hydration. Run the AI Agent Compatibility audit on several deep routes, then give an agent you have access to a realistic task on a staging copy and watch where it stalls. Note each hesitation and compare it with the audit findings.
The AI Agent Compatibility audit tests your page for bot blocks, content hidden behind JavaScript, unlabelled forms and controls, navigation agents cannot follow and missing structured data, and links each finding to the guide that fixes it.
Open AI Agent Compatibility Fixes โ