diff --git a/examples/react/next-app-router/app/landing/page.tsx b/examples/react/next-app-router/app/landing/page.tsx
new file mode 100644
index 00000000000..4221e44445a
--- /dev/null
+++ b/examples/react/next-app-router/app/landing/page.tsx
@@ -0,0 +1,17 @@
+import Link from 'next/link';
+import React from 'react';
+
+// A plain page with no InstantSearch, mirroring the reproduction in #7060
+// where the first soft-navigation lands on an InstantSearch page.
+export const dynamic = 'force-dynamic';
+
+export default function Landing() {
+ return (
+
+
Landing
+
+ Go to Appliances
+
+
+ );
+}
diff --git a/packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts b/packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts
new file mode 100644
index 00000000000..3c13e3de9ec
--- /dev/null
+++ b/packages/react-instantsearch-nextjs/__tests__/e2e/softNavigation.test.ts
@@ -0,0 +1,21 @@
+import { test, expect } from '@playwright/test';
+
+// Reproduces https://github.com/algolia/instantsearch/issues/7060:
+// on a client-side (soft) navigation that lands on the first InstantSearch page
+// of the session (here, coming from a page without InstantSearch), the
+// server-rendered results stopped being reflected and the page showed empty
+// hits until a full reload. The `/landing` page has no InstantSearch, so this
+// exercises exactly that case. Verified to fail before the fix (0 hits).
+test.describe('client-side navigation refreshes results', () => {
+ test('renders results when navigating from a page without InstantSearch', async ({
+ page,
+ }) => {
+ await page.goto('/landing');
+
+ await page.locator('#to-appliances').click();
+ await expect(page).toHaveURL('http://localhost:3000/Appliances');
+
+ await expect(page.locator('.ais-Hits-item').first()).toBeVisible();
+ await expect(page.locator('.ais-Hits-item')).not.toHaveCount(0);
+ });
+});
diff --git a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx
index c32939ac5ec..8ccc6ab5762 100644
--- a/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx
+++ b/packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx
@@ -7,6 +7,7 @@ import {
} from 'react-instantsearch-core';
import { InitializePromise } from './InitializePromise';
+import { RefreshOnClientNavigation } from './RefreshOnClientNavigation';
import { TriggerSearch } from './TriggerSearch';
import { useDynamicRouteWarning } from './useDynamicRouteWarning';
import { useInstantSearchRouting } from './useInstantSearchRouting';
@@ -85,6 +86,7 @@ export function InstantSearchNext<
{isServer && }
{children}
{isServer && }
+ {!isServer && }
);
diff --git a/packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts b/packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts
new file mode 100644
index 00000000000..de724308ef6
--- /dev/null
+++ b/packages/react-instantsearch-nextjs/src/RefreshOnClientNavigation.ts
@@ -0,0 +1,82 @@
+import { useEffect, useRef } from 'react';
+import { useInstantSearchContext } from 'react-instantsearch-core';
+
+// Fallback store for the first location an `InstantSearchNext` instance
+// rendered with, used only in environments without the Navigation Timing API
+// (e.g. jsdom in tests). See `getInitialLocation` below.
+const InstantSearchDocumentLocation = Symbol.for(
+ 'InstantSearchDocumentLocation'
+);
+declare global {
+ interface Window {
+ [InstantSearchDocumentLocation]?: string;
+ }
+}
+
+/**
+ * The `pathname` + `search` the document was initially loaded with.
+ *
+ * It comes from the Navigation Timing API, which reflects the hard page load
+ * and is *not* affected by client-side (SPA) navigations. The hash is ignored
+ * as it isn't part of the routing state. Falls back to the first location any
+ * instance rendered with (stored on `window`) when Navigation Timing is
+ * unavailable.
+ */
+function getInitialLocation(currentLocation: string): string {
+ try {
+ const [entry] = performance.getEntriesByType('navigation');
+ if (entry && entry.name) {
+ const url = new URL(entry.name);
+ return url.pathname + url.search;
+ }
+ } catch (e) {
+ // Navigation Timing not available; fall through to the `window` fallback.
+ }
+
+ if (window[InstantSearchDocumentLocation] === undefined) {
+ window[InstantSearchDocumentLocation] = currentLocation;
+ }
+ return window[InstantSearchDocumentLocation];
+}
+
+/**
+ * Whether the current location differs from the one the document was initially
+ * loaded with — i.e. whether we reached this render through a client-side (SPA)
+ * navigation rather than the initial hydration.
+ */
+export function isClientNavigation(): boolean {
+ const currentLocation = window.location.pathname + window.location.search;
+ return getInitialLocation(currentLocation) !== currentLocation;
+}
+
+/**
+ * Refreshes the search after a client-side navigation.
+ *
+ * On a client-side navigation the App Router remounts `InstantSearchNext` with
+ * the server-streamed `initialResults`, which can be empty (e.g. coming from a
+ * page without InstantSearch) or stale, and the instance doesn't search again
+ * on its own — leaving the new page with no or outdated hits until a full
+ * reload (#7060). When we detect such a navigation we refresh the search so it
+ * re-fetches with the (already correct) current UI state.
+ *
+ * Unlike re-running the router's `onUpdate`, `refresh()` doesn't touch the URL,
+ * so it can't wipe the URL of a nested `` whose children only register
+ * on a second render pass (#6980).
+ */
+export function RefreshOnClientNavigation() {
+ const search = useInstantSearchContext();
+ const hasRefreshed = useRef(false);
+
+ useEffect(() => {
+ if (hasRefreshed.current) {
+ return;
+ }
+ hasRefreshed.current = true;
+
+ if (search.started && isClientNavigation()) {
+ search.refresh();
+ }
+ }, [search]);
+
+ return null;
+}
diff --git a/packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx
new file mode 100644
index 00000000000..f9b2f9681cf
--- /dev/null
+++ b/packages/react-instantsearch-nextjs/src/__tests__/RefreshOnClientNavigation.test.tsx
@@ -0,0 +1,165 @@
+/**
+ * @jest-environment @instantsearch/testutils/jest-environment-jsdom.ts
+ */
+
+import { createSearchClient } from '@instantsearch/mocks';
+import { wait } from '@instantsearch/testutils';
+import { act, render } from '@testing-library/react';
+import React from 'react';
+import { Index, SearchBox } from 'react-instantsearch';
+import { useInstantSearchContext } from 'react-instantsearch-core';
+
+import { InstantSearchNext } from '../InstantSearchNext';
+import { isClientNavigation } from '../RefreshOnClientNavigation';
+
+import type { InstantSearch } from 'instantsearch.js';
+
+const mockPathname = jest.fn();
+const mockSearchParams = jest.fn();
+jest.mock('next/navigation', () => ({
+ ...jest.requireActual('next/navigation'),
+ usePathname() {
+ return mockPathname();
+ },
+ useSearchParams() {
+ return mockSearchParams();
+ },
+}));
+
+// The component reads the document's initial URL from the Navigation Timing
+// API to tell hydration apart from a client-side navigation. jsdom doesn't
+// implement it, so we define it (which also exercises the production path
+// rather than the `window` fallback). `navigationInitialHref` models reality:
+// it stays fixed at the hard-loaded URL while client-side navigations change
+// `window.location`. `null` means "no navigation yet" (hydration), so the entry
+// reflects the current location.
+let navigationInitialHref: string | null = null;
+
+// Captures the InstantSearch instance and spies on `refresh` during render, so
+// the spy is installed before the `RefreshOnClientNavigation` effect runs.
+let capturedSearch: InstantSearch | null = null;
+function CaptureInstance() {
+ const search = useInstantSearchContext();
+ if (!jest.isMockFunction(search.refresh)) {
+ jest.spyOn(search, 'refresh');
+ }
+ capturedSearch = search;
+ return null;
+}
+
+beforeEach(() => {
+ mockPathname.mockReturnValue('/search');
+ mockSearchParams.mockReturnValue(new URLSearchParams());
+ window.history.replaceState({}, '', '/search');
+ navigationInitialHref = null;
+ capturedSearch = null;
+ Object.defineProperty(performance, 'getEntriesByType', {
+ configurable: true,
+ value: () => [{ name: navigationInitialHref ?? window.location.href }],
+ });
+});
+
+afterEach(() => {
+ delete (performance as unknown as Record).getEntriesByType;
+});
+
+describe('isClientNavigation', () => {
+ test('is false when the current location matches the initial document', () => {
+ window.history.replaceState({}, '', '/search?q=iphone');
+ expect(isClientNavigation()).toBe(false);
+ });
+
+ test('is true when the pathname differs from the initial document', () => {
+ navigationInitialHref = 'http://localhost/other';
+ window.history.pushState({}, '', '/search');
+ expect(isClientNavigation()).toBe(true);
+ });
+
+ test('is true when only the search params differ from the initial document', () => {
+ navigationInitialHref = 'http://localhost/search?q=old';
+ window.history.pushState({}, '', '/search?q=new');
+ expect(isClientNavigation()).toBe(true);
+ });
+});
+
+describe('RefreshOnClientNavigation', () => {
+ test('refreshes the search on a client-side navigation', async () => {
+ // Document loaded elsewhere, then client-navigated to `/search`.
+ navigationInitialHref = 'http://localhost/other';
+ window.history.pushState({}, '', '/search');
+
+ await act(async () => {
+ render(
+
+
+
+
+ );
+ });
+ await act(async () => {
+ await wait(0);
+ });
+
+ expect(capturedSearch!.refresh).toHaveBeenCalledTimes(1);
+ });
+
+ test('does not refresh on the initial hydration', async () => {
+ await act(async () => {
+ render(
+
+
+
+
+ );
+ });
+ await act(async () => {
+ await wait(0);
+ });
+
+ expect(capturedSearch!.refresh).not.toHaveBeenCalled();
+ });
+
+ // The fix must not reintroduce the #6980 URL wipe on a soft navigation: when
+ // landing on a nested- page via client-side navigation, refreshing the
+ // results must not touch the URL (refresh() doesn't, unlike re-running the
+ // router's onUpdate before the nested children register).
+ test('preserves the URL on a soft navigation to a nested ', async () => {
+ const indexName = 'instant_search';
+ const indexId = `${indexName}_web`;
+
+ navigationInitialHref = 'http://localhost/other';
+ mockSearchParams.mockReturnValue(new URLSearchParams('q=iphone'));
+ window.history.pushState({}, '', '/search?q=iphone');
+
+ await act(async () => {
+ render(
+
+
+
+
+
+
+ );
+ });
+ await act(async () => {
+ await wait(0);
+ });
+
+ expect(capturedSearch!.refresh).toHaveBeenCalledTimes(1);
+ expect(window.location.search).toBe('?q=iphone');
+ });
+});