diff --git a/e2e/test-app/src/pages/_app.tsx b/e2e/test-app/src/pages/_app.tsx
index f53ea50..4bc14a9 100644
--- a/e2e/test-app/src/pages/_app.tsx
+++ b/e2e/test-app/src/pages/_app.tsx
@@ -18,17 +18,14 @@ function MyApp({ Component, pageProps, nextlyticsCtx }: MyAppProps) {
MyApp.getInitialProps = async (appContext: AppContext) => {
const { ctx } = appContext;
- // Only get nextlytics props on server-side (when req is available)
- let nextlyticsCtx: NextlyticsContext = { requestId: "" };
- if (ctx.req) {
- nextlyticsCtx = getNextlyticsProps({ req: { headers: ctx.req.headers } });
- }
-
+ // getInitialProps re-runs in the browser on client-side navigation, where
+ // there is no `ctx.req`. getNextlyticsProps handles that and returns an empty
+ // context; the client keeps the templates and scripts it already has.
return {
pageProps: appContext.Component.getInitialProps
? await appContext.Component.getInitialProps(ctx)
: {},
- nextlyticsCtx,
+ nextlyticsCtx: getNextlyticsProps(ctx),
};
};
diff --git a/e2e/tests/analytics.test.ts b/e2e/tests/analytics.test.ts
index b603b36..0e6352e 100644
--- a/e2e/tests/analytics.test.ts
+++ b/e2e/tests/analytics.test.ts
@@ -194,6 +194,33 @@ describe.each(versions)("%s", (version) => {
await page.close();
});
+ it("compiles and runs client-side script templates on initial load", async () => {
+ // Regression guard for template delivery. App Router gets templates from
+ // NextlyticsServer; Pages Router can't read config in getNextlyticsProps,
+ // so it receives them in the /api/event response. Either way the scripts
+ // must compile and run — without the template, __nextlyticsTestInit stays
+ // undefined and this times out.
+ const page = await testApp.newPage();
+
+ await testApp.visitHome(page);
+
+ await page.waitForFunction(() => window.__nextlyticsTestInit !== undefined, undefined, {
+ timeout: 5000,
+ });
+
+ const counters = await page.evaluate(() => ({
+ init: window.__nextlyticsTestInit,
+ config: window.__nextlyticsTestConfig,
+ event: window.__nextlyticsTestEvent,
+ }));
+
+ expect(counters.init).toBeGreaterThanOrEqual(1);
+ expect(counters.config).toBeGreaterThanOrEqual(1);
+ expect(counters.event).toBeGreaterThanOrEqual(1);
+
+ await page.close();
+ });
+
it("script modes work correctly during soft navigation", async () => {
// This test only applies to App Router (soft navigation with )
if (routerType !== "app") return;
@@ -264,11 +291,11 @@ describe.each(versions)("%s", (version) => {
});
});
-// Type augmentation for test globals
+// Type augmentation for test globals (set by the console-test backend scripts)
declare global {
interface Window {
- __nextlyticsTestOnce?: number;
- __nextlyticsTestParamsChange?: number;
- __nextlyticsTestEveryRender?: number;
+ __nextlyticsTestInit?: number;
+ __nextlyticsTestConfig?: number;
+ __nextlyticsTestEvent?: number;
}
}
diff --git a/packages/core/src/api-handler.ts b/packages/core/src/api-handler.ts
index f51f91d..3a285e2 100644
--- a/packages/core/src/api-handler.ts
+++ b/packages/core/src/api-handler.ts
@@ -8,6 +8,7 @@ import type {
ClientContext,
ClientRequest,
DispatchResult,
+ JavascriptTemplate,
PageViewDelivery,
NextlyticsEvent,
RequestContext,
@@ -30,6 +31,9 @@ export type UpdateEvent = (
ctx: RequestContext
) => Promise;
+/** Collect the client-side templates from the configured backends. */
+export type CollectTemplates = (ctx: RequestContext) => Record;
+
type HandlerContext = {
pageRenderId: string;
isSoftNavigation: boolean;
@@ -39,8 +43,26 @@ type HandlerContext = {
config: NextlyticsConfigWithDefaults;
dispatchEvent: DispatchEvent;
updateEvent: UpdateEvent;
+ collectTemplates: CollectTemplates;
+ /** Template ids the client already holds (from the known-templates header). */
+ knownTemplateIds: Set;
};
+/**
+ * Collect the templates for this request and drop the ones the client already
+ * has. App Router clients already hold the full set (from the ctx prop), so they
+ * get nothing back; a Pages Router client receives each template once. Returns
+ * undefined when there is nothing new, to keep it out of the JSON response.
+ */
+function newTemplatesFor(hctx: HandlerContext): Record | undefined {
+ const all = hctx.collectTemplates(hctx.ctx);
+ const missing: Record = {};
+ for (const [id, template] of Object.entries(all)) {
+ if (!hctx.knownTemplateIds.has(id)) missing[id] = template;
+ }
+ return Object.keys(missing).length > 0 ? missing : undefined;
+}
+
function createRequestContext(request: NextRequest): RequestContext {
return {
headers: request.headers,
@@ -158,6 +180,7 @@ async function handleClientInit(
return Response.json({
ok: true,
items: filterScripts(actions),
+ templates: newTemplatesFor(hctx),
});
}
@@ -167,7 +190,7 @@ async function handleClientInit(
after(() => completion);
after(() => updateEvent(pageRenderId, { clientContext, userContext, anonymousUserId }, ctx));
- return Response.json({ ok: true });
+ return Response.json({ ok: true, templates: newTemplatesFor(hctx) });
}
async function handleClientEvent(
@@ -208,14 +231,19 @@ async function handleClientEvent(
const actions = await clientActions;
after(() => completion);
- return Response.json({ ok: true, items: filterScripts(actions) });
+ return Response.json({
+ ok: true,
+ items: filterScripts(actions),
+ templates: newTemplatesFor(hctx),
+ });
}
export async function handleEventPost(
request: NextRequest,
config: NextlyticsConfigWithDefaults,
dispatchEvent: DispatchEvent,
- updateEvent: UpdateEvent
+ updateEvent: UpdateEvent,
+ collectTemplates: CollectTemplates
): Promise {
const softNavHeader = request.headers.get(analyticsHeaders.isSoftNavigation);
const isSoftNavigation = softNavHeader === "1";
@@ -235,6 +263,16 @@ export async function handleEventPost(
const apiCallServerContext = createServerContext(request);
const userContext = await getUserContext(config, ctx);
+ const knownTemplatesHeader = request.headers.get(analyticsHeaders.knownTemplates);
+ const knownTemplateIds = new Set(
+ knownTemplatesHeader
+ ? knownTemplatesHeader
+ .split(",")
+ .map((id) => id.trim())
+ .filter(Boolean)
+ : []
+ );
+
const cookiePageRenderId = request.cookies.get(LAST_PAGE_RENDER_ID_COOKIE)?.value;
const pageRenderId = isSoftNavigation ? (cookiePageRenderId ?? generateId()) : pageRenderIdHeader;
if (isSoftNavigation && !cookiePageRenderId && config.debug) {
@@ -252,6 +290,8 @@ export async function handleEventPost(
config,
dispatchEvent,
updateEvent,
+ collectTemplates,
+ knownTemplateIds,
};
const bodyType = body.type;
diff --git a/packages/core/src/client.tsx b/packages/core/src/client.tsx
index da963f3..e5f1321 100644
--- a/packages/core/src/client.tsx
+++ b/packages/core/src/client.tsx
@@ -9,6 +9,7 @@ import {
useMemo,
useReducer,
useRef,
+ useState,
} from "react";
import { useNavigation, debug, InjectScript, type InjectScriptProps } from "./client-utils";
import type {
@@ -41,6 +42,8 @@ type NextlyticsContextValue = {
addScripts: (scripts: TemplatizedScriptInsertion[]) => void;
scriptsRef: React.MutableRefObject[]>;
subscribersRef: React.MutableRefObject void>>;
+ mergeTemplates: (incoming?: Record) => void;
+ knownTemplateIdsRef: React.MutableRefObject;
};
const NextlyticsContext = createContext(null);
@@ -227,7 +230,11 @@ function NextlyticsScripts({
async function sendEventToServer(
requestId: string,
request: ClientRequest,
- { signal, isSoftNavigation }: { signal?: AbortSignal; isSoftNavigation?: boolean } = {}
+ {
+ signal,
+ isSoftNavigation,
+ knownTemplateIds,
+ }: { signal?: AbortSignal; isSoftNavigation?: boolean; knownTemplateIds?: string[] } = {}
): Promise {
try {
const headers: Record = {
@@ -237,6 +244,11 @@ async function sendEventToServer(
if (isSoftNavigation) {
headers[headerNames.isSoftNavigation] = "1";
}
+ // Tell the server which templates we already have so it only sends new ones.
+ // App Router clients already hold the full set, so nothing comes back.
+ if (knownTemplateIds?.length) {
+ headers[headerNames.knownTemplates] = knownTemplateIds.join(",");
+ }
const response = await fetch("/api/event", {
method: "POST",
headers,
@@ -251,9 +263,9 @@ async function sendEventToServer(
return { ok: false };
}
- // Parse response to get scripts
+ // Parse response to get scripts (and templates, for Pages Router clients)
const data = await response.json().catch(() => ({ ok: response.ok }));
- return { ok: data.ok ?? response.ok, items: data.items };
+ return { ok: data.ok ?? response.ok, items: data.items, templates: data.templates };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return { ok: false };
@@ -264,12 +276,43 @@ async function sendEventToServer(
}
export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: ReactNode }) {
- const { requestId, scripts: initialScripts = [], templates = {} } = props.ctx;
+ const { requestId, scripts: initialScripts = [] } = props.ctx;
// Refs for dynamic scripts (from sendEvent calls) - stable, no re-renders
const scriptsRef = useRef[]>([]);
const subscribersRef = useRef void>>(new Set());
+ // Templates can arrive from two places: the ctx prop (App Router's
+ // NextlyticsServer collects them from config) and the /api/event response
+ // (Pages Router, where getNextlyticsProps has no access to config). Hold them
+ // in state and merge from whichever source supplies them, so they survive
+ // client-side navigations regardless of router.
+ const [templates, setTemplates] = useState>(
+ () => props.ctx.templates ?? {}
+ );
+ const mergeTemplates = useCallback((incoming?: Record) => {
+ if (!incoming) return;
+ const keys = Object.keys(incoming);
+ if (keys.length === 0) return;
+ setTemplates((prev) => {
+ const hasNew = keys.some((k) => prev[k] !== incoming[k]);
+ return hasNew ? { ...prev, ...incoming } : prev;
+ });
+ }, []);
+
+ // Merge templates supplied via the ctx prop (App Router).
+ useEffect(() => {
+ mergeTemplates(props.ctx.templates);
+ }, [props.ctx.templates, mergeTemplates]);
+
+ // Mirror the template ids into a ref so sendEventToServer can tell the server
+ // which templates we already have (sent as a header), without re-creating the
+ // request callbacks on every merge.
+ const knownTemplateIdsRef = useRef(Object.keys(props.ctx.templates ?? {}));
+ useEffect(() => {
+ knownTemplateIdsRef.current = Object.keys(templates);
+ }, [templates]);
+
const addScripts = useCallback((newScripts: TemplatizedScriptInsertion[]) => {
debug("Adding scripts", {
newCount: newScripts.length,
@@ -281,8 +324,16 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea
// Context value is stable - refs don't change identity
const contextValue = useMemo(
- () => ({ requestId, templates, addScripts, scriptsRef, subscribersRef }),
- [requestId, templates, addScripts]
+ () => ({
+ requestId,
+ templates,
+ addScripts,
+ scriptsRef,
+ subscribersRef,
+ mergeTemplates,
+ knownTemplateIdsRef,
+ }),
+ [requestId, templates, addScripts, mergeTemplates]
);
// Send page-view on mount and soft navigations
@@ -292,9 +343,10 @@ export function NextlyticsClient(props: { ctx: NextlyticsContext; children?: Rea
sendEventToServer(
requestId,
{ type: "page-view", clientContext, softNavigation: softNavigation || undefined },
- { signal, isSoftNavigation: softNavigation }
- ).then(({ items }) => {
+ { signal, isSoftNavigation: softNavigation, knownTemplateIds: knownTemplateIdsRef.current }
+ ).then(({ items, templates: responseTemplates }) => {
debug("page-view response", { scriptsCount: items?.length ?? 0 });
+ mergeTemplates(responseTemplates);
if (items?.length) addScripts(items);
});
});
@@ -324,28 +376,33 @@ export function useNextlytics(): NextlyticsClientApi {
);
}
- const { requestId, addScripts } = context;
+ const { requestId, addScripts, mergeTemplates, knownTemplateIdsRef } = context;
const sendEvent = useCallback(
async (
eventName: string,
opts?: { props?: Record }
): Promise<{ ok: boolean }> => {
- const result = await sendEventToServer(requestId, {
- type: "custom-event",
- name: eventName,
- props: opts?.props,
- collectedAt: new Date().toISOString(),
- clientContext: createClientContext(),
- });
+ const result = await sendEventToServer(
+ requestId,
+ {
+ type: "custom-event",
+ name: eventName,
+ props: opts?.props,
+ collectedAt: new Date().toISOString(),
+ clientContext: createClientContext(),
+ },
+ { knownTemplateIds: knownTemplateIdsRef.current }
+ );
+ mergeTemplates(result.templates);
if (result.items && result.items.length > 0) {
addScripts(result.items);
}
return { ok: result.ok };
},
- [requestId, addScripts]
+ [requestId, addScripts, mergeTemplates, knownTemplateIdsRef]
);
return { sendEvent };
diff --git a/packages/core/src/middleware.ts b/packages/core/src/middleware.ts
index 0ba2604..6a9a9a2 100644
--- a/packages/core/src/middleware.ts
+++ b/packages/core/src/middleware.ts
@@ -19,6 +19,7 @@ import {
handleEventPost,
getUserContext,
getEventProps,
+ type CollectTemplates,
type DispatchEvent,
type UpdateEvent,
} from "./api-handler";
@@ -34,7 +35,8 @@ function createRequestContext(request: NextRequest): RequestContext {
export function createNextlyticsMiddleware(
config: NextlyticsConfigWithDefaults,
dispatchEvent: DispatchEvent,
- updateEvent: UpdateEvent
+ updateEvent: UpdateEvent,
+ collectTemplates: CollectTemplates
): NextMiddleware {
const { eventEndpoint } = config;
@@ -74,7 +76,7 @@ export function createNextlyticsMiddleware(
// Handle event endpoint directly in middleware
if (pathname === eventEndpoint) {
if (request.method === "POST") {
- return handleEventPost(request, config, dispatchEvent, updateEvent);
+ return handleEventPost(request, config, dispatchEvent, updateEvent, collectTemplates);
}
return Response.json({ error: "Method not allowed" }, { status: 405 });
}
diff --git a/packages/core/src/pages-router.ts b/packages/core/src/pages-router.ts
index c259bb5..b598fd5 100644
--- a/packages/core/src/pages-router.ts
+++ b/packages/core/src/pages-router.ts
@@ -2,16 +2,29 @@ import type { NextlyticsContext } from "./client";
import { restoreServerComponentContext } from "./server-component-context";
export type PagesRouterContext = {
- req: { headers: Record; cookies?: Record };
+ req?: {
+ headers: Record;
+ cookies?: Record;
+ };
};
/**
* Get Nextlytics props for Pages Router _app.tsx.
* Reads context from headers set by middleware.
+ *
+ * `_app`'s getInitialProps re-runs on every client-side navigation, where there
+ * is no `req`. Return an empty context in that case rather than throwing — the
+ * client already has its scripts and templates from the initial render and the
+ * /api/event round-trip.
*/
export function getNextlyticsProps(ctx: PagesRouterContext): NextlyticsContext {
+ const reqHeaders = ctx?.req?.headers;
+ if (!reqHeaders) {
+ return { requestId: "" };
+ }
+
const headersList = new Headers();
- for (const [key, value] of Object.entries(ctx.req.headers)) {
+ for (const [key, value] of Object.entries(reqHeaders)) {
if (value) {
headersList.set(key, Array.isArray(value) ? value[0] : value);
}
diff --git a/packages/core/src/server-component-context.ts b/packages/core/src/server-component-context.ts
index 8b8257a..f637c33 100644
--- a/packages/core/src/server-component-context.ts
+++ b/packages/core/src/server-component-context.ts
@@ -10,6 +10,9 @@ const headerNames = {
isSoftNavigation: `${HEADER_PREFIX}is-soft-nav`,
active: `${HEADER_PREFIX}active`,
scripts: `${HEADER_PREFIX}scripts`,
+ /** Comma-separated template ids the client already holds, so the server only
+ * returns the ones it's missing (see api-handler / client templates merge). */
+ knownTemplates: `${HEADER_PREFIX}known-templates`,
} as const;
export const LAST_PAGE_RENDER_ID_COOKIE = "last-page-render-id";
diff --git a/packages/core/src/server.tsx b/packages/core/src/server.tsx
index f6a2e33..5fe93c9 100644
--- a/packages/core/src/server.tsx
+++ b/packages/core/src/server.tsx
@@ -235,7 +235,12 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
return updateEventInternal(eventId, patch, ctx);
};
- const middleware = createNextlyticsMiddleware(config, dispatchEventInternal, updateEventInternal);
+ const middleware = createNextlyticsMiddleware(
+ config,
+ dispatchEventInternal,
+ updateEventInternal,
+ (ctx) => collectTemplates(config, ctx)
+ );
/** Server component that provides analytics context to the app */
async function Server({ children }: { children: ReactNode }) {
@@ -245,7 +250,9 @@ export function Nextlytics(userConfig: NextlyticsConfig): NextlyticsResult {
if (!ctx) {
// x-nl-page-render-id absent → check if middleware is at least active
if (!headersList.get(headerNames.active)) {
- console.warn("[Nextlytics] nextlyticsMiddleware should be added in order for Server to work");
+ console.warn(
+ "[Nextlytics] nextlyticsMiddleware should be added in order for Server to work"
+ );
}
return <>{children}>;
}
diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts
index 0632fa3..e3260c7 100644
--- a/packages/core/src/types.ts
+++ b/packages/core/src/types.ts
@@ -292,6 +292,9 @@ export type ClientRequest =
export type ClientRequestResult = {
ok: boolean;
items?: ClientActionItem[];
+ /** Client-side templates from the backends, so Pages Router clients (which
+ * can't read them from config) can compile the script insertions. */
+ templates?: Record;
};
/** Return value from Nextlytics() */