diff --git a/docs/content/scripts/speedcurve.md b/docs/content/scripts/speedcurve.md
index 68bd3f831..0c18d7013 100644
--- a/docs/content/scripts/speedcurve.md
+++ b/docs/content/scripts/speedcurve.md
@@ -20,6 +20,40 @@ The composable comes with the following defaults:
- **Trigger: Client** The LUX primer is injected into `
`{lang="html"} immediately; `lux.js` loads when Nuxt hydrates.
+## Setup
+
+SpeedCurve LUX is opt-in. You **must** register it in `scripts.registry.speedcurve` before calling `useScriptSpeedCurve`, even if you're not auto-loading globally; registration is what triggers the module to resolve and inline the LUX primer at build time. Install the `@speedcurve/lux` peer dep alongside:
+
+```bash
+npm i -D @speedcurve/lux
+```
+
+```ts [nuxt.config.ts: composable-only (no global load)]
+export default defineNuxtConfig({
+ modules: ['@nuxt/scripts'],
+ scripts: {
+ registry: {
+ // Minimum registration — enables the composable per-page.
+ // Pass `id` here and you can omit it from each useScriptSpeedCurve() call.
+ speedcurve: {},
+ },
+ },
+})
+```
+
+```ts [nuxt.config.ts: auto-load globally]
+export default defineNuxtConfig({
+ modules: ['@nuxt/scripts'],
+ scripts: {
+ registry: {
+ speedcurve: { id: 'YOUR_SPEEDCURVE_ID', trigger: 'onNuxtReady' },
+ },
+ },
+})
+```
+
+If `speedcurve` isn't registered, builds fail with an unresolved `#build/nuxt-scripts-speedcurve-snippet` import. If it's registered but `@speedcurve/lux` is missing, you'll see an install hint at runtime when LUX initialises. Pinning your own `@speedcurve/lux` version means you control when the primer snippet updates.
+
You can access the `LUX` object as a proxy directly, or await `$script` to get the loaded instance.
::code-group
diff --git a/packages/script/package.json b/packages/script/package.json
index f8bedf358..c4bbbf356 100644
--- a/packages/script/package.json
+++ b/packages/script/package.json
@@ -68,7 +68,6 @@
"unimport",
"#nuxt-scripts/types",
"posthog-js",
- "@speedcurve/lux",
"@nuxt/devtools-kit",
"sirv"
]
diff --git a/packages/script/src/module.ts b/packages/script/src/module.ts
index e3ae9fd18..4cab62285 100644
--- a/packages/script/src/module.ts
+++ b/packages/script/src/module.ts
@@ -659,6 +659,25 @@ export default defineNuxtModule({
},
})
+ // SpeedCurve requires opt-in via `scripts.registry.speedcurve` plus the
+ // `@speedcurve/lux` peer dep so the user controls the snippet version.
+ // Template only emitted on registration; non-registered consumers of
+ // useScriptSpeedCurve hit a build error from the unresolved virtual.
+ if (config.registry?.speedcurve) {
+ addTemplate({
+ filename: 'nuxt-scripts-speedcurve-snippet.mjs',
+ async getContents() {
+ const snippetPath = await resolvePath('@speedcurve/lux/dist/lux-snippet.js').catch(() => null)
+ // Named export must exist or ESM instantiation fails before the
+ // install hint is ever read, hence the IIFE initializer pattern.
+ if (!snippetPath || !existsSync(snippetPath))
+ return `export const luxSnippetSource = (() => { throw new Error('[nuxt-scripts] useScriptSpeedCurve requires the @speedcurve/lux package. Install it with: npm i -D @speedcurve/lux') })()\n`
+ const source = readFileSync(snippetPath, 'utf-8')
+ return `export const luxSnippetSource = ${JSON.stringify(source)}\n`
+ },
+ })
+ }
+
logger.debug('[nuxt-scripts] Proxy prefix:', proxyPrefix)
for (const script of scripts) {
diff --git a/packages/script/src/registry-types.json b/packages/script/src/registry-types.json
index 8aec6c6f5..a23513d73 100644
--- a/packages/script/src/registry-types.json
+++ b/packages/script/src/registry-types.json
@@ -970,7 +970,7 @@
{
"name": "SpeedCurveOptions",
"kind": "const",
- "code": "export const SpeedCurveOptions = object({\n /**\n * Your SpeedCurve customer ID.\n * @see https://support.speedcurve.com/docs/add-rum-to-your-site\n */\n id: pipe(string(), minLength(1)),\n /**\n * Enable SPA (single-page application) mode.\n * When true, lux.js tracks soft navigations instead of full page loads.\n * @see https://support.speedcurve.com/docs/single-page-applications\n */\n spaMode: optional(boolean()),\n /**\n * Automatically wire Vue Router hooks for SPA tracking when spaMode is true.\n * Set to false to instrument navigations manually.\n * @default true (when spaMode is true)\n */\n autoTrackSpaNavigations: optional(boolean()),\n /**\n * Page label shown in the SpeedCurve dashboard.\n * Accepts a static string, a function `(to) => string` for per-navigation labels,\n * or `false` to disable labeling entirely.\n * @default String(to.name ?? to.path)\n */\n label: optional(union([string(), function_(), literal(false)])),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(pipe(number(), minValue(0), maxValue(100))),\n /**\n * Send the beacon when the page is hidden (pagehide event).\n * @default true\n */\n sendBeaconOnPageHidden: optional(boolean()),\n /**\n * Track JavaScript errors.\n * @default true\n */\n trackErrors: optional(boolean()),\n /**\n * Maximum number of errors to track per page view.\n * @default 5\n */\n maxErrors: optional(number()),\n /**\n * Minimum time (ms) before a beacon can be sent.\n */\n minMeasureTime: optional(number()),\n /**\n * Maximum time (ms) after which the beacon is sent regardless of load state.\n * @default 60000\n */\n maxMeasureTime: optional(number()),\n /**\n * Start a new beacon when the page becomes visible after being hidden.\n */\n newBeaconOnPageShow: optional(boolean()),\n /**\n * Track pages loaded in background tabs.\n * @default false\n */\n trackHiddenPages: optional(boolean()),\n /**\n * Cookie domain for cross-subdomain session tracking.\n */\n cookieDomain: optional(string()),\n})"
+ "code": "export const SpeedCurveOptions = object({\n // -- Composable-only options (consumed by useScriptSpeedCurve, not forwarded to LUX) --\n /**\n * Your SpeedCurve customer ID.\n * @see https://support.speedcurve.com/docs/add-rum-to-your-site\n */\n id: pipe(string(), minLength(1)),\n /**\n * Enable SPA (single-page application) mode.\n * When true, lux.js tracks soft navigations instead of full page loads.\n * @see https://support.speedcurve.com/docs/single-page-applications\n */\n spaMode: optional(boolean()),\n /**\n * Automatically wire Vue Router hooks for SPA tracking when spaMode is true.\n * Set to false to instrument navigations manually.\n * @default true (when spaMode is true)\n */\n autoTrackSpaNavigations: optional(boolean()),\n\n // -- LUX UserConfig passthrough (forwarded onto window.LUX before lux.js loads) --\n // Property names match upstream LUX UserConfig casing exactly. Keep this list in\n // sync with the LUX_USER_CONFIG_KEYS filter in speedcurve.ts.\n /**\n * Page label shown in the SpeedCurve dashboard.\n * Accepts a static string, a function `(to) => string | false` for per-navigation labels,\n * or `false` to disable labeling entirely. A callback returning `false` skips updating\n * the label for that navigation.\n * @default String(to.name ?? to.path)\n */\n label: optional(union([string(), function_(), literal(false)])),\n /**\n * Sampling rate (0–100). Percentage of sessions that send beacons.\n * Upstream spelling is lowercase — matches LUX UserConfig.\n */\n samplerate: optional(pipe(number(), minValue(0), maxValue(100))),\n /**\n * Send the beacon when the page is hidden (pagehide event).\n * @default true\n */\n sendBeaconOnPageHidden: optional(boolean()),\n /**\n * Track JavaScript errors.\n * @default true\n */\n trackErrors: optional(boolean()),\n /**\n * Maximum number of errors to track per page view.\n * @default 5\n */\n maxErrors: optional(number()),\n /**\n * Minimum time (ms) before a beacon can be sent.\n */\n minMeasureTime: optional(number()),\n /**\n * Maximum time (ms) after which the beacon is sent regardless of load state.\n * @default 60000\n */\n maxMeasureTime: optional(number()),\n /**\n * Start a new beacon when the page becomes visible after being hidden.\n */\n newBeaconOnPageShow: optional(boolean()),\n /**\n * Track pages loaded in background tabs.\n * @default false\n */\n trackHiddenPages: optional(boolean()),\n /**\n * Cookie domain for cross-subdomain session tracking.\n */\n cookieDomain: optional(string()),\n})"
},
{
"name": "SpeedCurveApi",
@@ -2411,7 +2411,7 @@
"name": "label",
"type": "string | Function | false",
"required": false,
- "description": "Page label shown in the SpeedCurve dashboard. Accepts a static string, a function `(to) => string` for per-navigation labels, or `false` to disable labeling entirely.",
+ "description": "Page label shown in the SpeedCurve dashboard. Accepts a static string, a function `(to) => string | false` for per-navigation labels, or `false` to disable labeling entirely. A callback returning `false` skips updating the label for that navigation.",
"defaultValue": "String(to.name ?? to.path)"
},
{
diff --git a/packages/script/src/runtime/registry/schemas.ts b/packages/script/src/runtime/registry/schemas.ts
index 3e3452077..d5dd19ff2 100644
--- a/packages/script/src/runtime/registry/schemas.ts
+++ b/packages/script/src/runtime/registry/schemas.ts
@@ -1008,6 +1008,7 @@ export const InitObjectPropertiesSchema = object({
})
export const SpeedCurveOptions = object({
+ // -- Composable-only options (consumed by useScriptSpeedCurve, not forwarded to LUX) --
/**
* Your SpeedCurve customer ID.
* @see https://support.speedcurve.com/docs/add-rum-to-your-site
@@ -1025,10 +1026,15 @@ export const SpeedCurveOptions = object({
* @default true (when spaMode is true)
*/
autoTrackSpaNavigations: optional(boolean()),
+
+ // -- LUX UserConfig passthrough (forwarded onto window.LUX before lux.js loads) --
+ // Property names match upstream LUX UserConfig casing exactly. Keep this list in
+ // sync with the LUX_USER_CONFIG_KEYS filter in speedcurve.ts.
/**
* Page label shown in the SpeedCurve dashboard.
- * Accepts a static string, a function `(to) => string` for per-navigation labels,
- * or `false` to disable labeling entirely.
+ * Accepts a static string, a function `(to) => string | false` for per-navigation labels,
+ * or `false` to disable labeling entirely. A callback returning `false` skips updating
+ * the label for that navigation.
* @default String(to.name ?? to.path)
*/
label: optional(union([string(), function_(), literal(false)])),
diff --git a/packages/script/src/runtime/registry/speedcurve.ts b/packages/script/src/runtime/registry/speedcurve.ts
index 18c82db07..1013075e7 100644
--- a/packages/script/src/runtime/registry/speedcurve.ts
+++ b/packages/script/src/runtime/registry/speedcurve.ts
@@ -1,8 +1,14 @@
import type { LuxGlobal, UserConfig } from '@speedcurve/lux'
import type { RouteLocationNormalized } from 'vue-router'
import type { RegistryScriptInput, UseScriptContext } from '#nuxt-scripts/types'
-import luxSnippetSource from '@speedcurve/lux/dist/lux-snippet.js?raw'
import { useHead, useNuxtApp, useRouter } from 'nuxt/app'
+// Virtual: emitted by the Nuxt module only when `speedcurve` is registered in
+// `scripts.registry`. Contents inline the LUX primer resolved from the
+// user-installed `@speedcurve/lux` peer dep at build time. Non-registered
+// users hit a build error from the unresolved virtual; registered users
+// without the peer dep get an install hint when the export is read.
+// @ts-expect-error virtual is only emitted when speedcurve is registered
+import { luxSnippetSource } from '#build/nuxt-scripts-speedcurve-snippet'
import { useRegistryScript } from '../utils'
import { afterNextPaint } from '../utils/after-next-paint'
import { SpeedCurveOptions } from './schemas'
diff --git a/playground/nuxt.config.ts b/playground/nuxt.config.ts
index 8869aa6f4..f65edd794 100644
--- a/playground/nuxt.config.ts
+++ b/playground/nuxt.config.ts
@@ -92,6 +92,7 @@ export default defineNuxtConfig({
databuddyAnalytics: { clientId: 'demo-client-123', trigger: 'manual' },
segment: { writeKey: 'KBXOGxgqMFjm2mxtJDJg0iDn5AnGYb9C', trigger: 'manual' },
posthog: { apiKey: 'phc_CkMaDU6dr11eJoQdAiSJb1rC324dogk3T952gJ6fD9W', trigger: 'manual' },
+ speedcurve: { id: 'DEMO_LUX_ID', trigger: 'manual' },
// Pixels — infrastructure only
metaPixel: { id: '3925006', trigger: 'manual' },
diff --git a/test/unit/__mocks__/empty.ts b/test/unit/__mocks__/empty.ts
new file mode 100644
index 000000000..336ce12bb
--- /dev/null
+++ b/test/unit/__mocks__/empty.ts
@@ -0,0 +1 @@
+export {}
diff --git a/test/unit/speedcurve-auto-tracker.test.ts b/test/unit/speedcurve-auto-tracker.test.ts
index c7fdef102..16ddf4cb6 100644
--- a/test/unit/speedcurve-auto-tracker.test.ts
+++ b/test/unit/speedcurve-auto-tracker.test.ts
@@ -21,8 +21,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
}))
-vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
- default: '/* lux snippet */',
+vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({
+ luxSnippetSource: '/* lux snippet */',
}))
describe('installAutoTracker', () => {
@@ -85,12 +85,12 @@ describe('installAutoTracker', () => {
injectHead: vi.fn(),
onNuxtReady: vi.fn(),
}))
- vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
- default: '/* lux snippet */',
- }))
vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
}))
+ vi.doMock('#build/nuxt-scripts-speedcurve-snippet', () => ({
+ luxSnippetSource: '/* lux snippet */',
+ }))
vi.clearAllMocks()
@@ -119,12 +119,12 @@ describe('installAutoTracker', () => {
injectHead: vi.fn(),
onNuxtReady: vi.fn(),
}))
- vi.doMock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
- default: '/* lux snippet */',
- }))
vi.doMock('../../packages/script/src/runtime/composables/useScript', () => ({
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
}))
+ vi.doMock('#build/nuxt-scripts-speedcurve-snippet', () => ({
+ luxSnippetSource: '/* lux snippet */',
+ }))
})
it('applies default label from route name', async () => {
diff --git a/test/unit/speedcurve-config.test.ts b/test/unit/speedcurve-config.test.ts
index 3e66c980e..5b71a0b05 100644
--- a/test/unit/speedcurve-config.test.ts
+++ b/test/unit/speedcurve-config.test.ts
@@ -17,8 +17,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
}))
-vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
- default: '/* lux snippet */',
+vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({
+ luxSnippetSource: '/* lux snippet */',
}))
describe('applyConfig', () => {
diff --git a/test/unit/speedcurve-primer.test.ts b/test/unit/speedcurve-primer.test.ts
index d03152ee3..0590382a2 100644
--- a/test/unit/speedcurve-primer.test.ts
+++ b/test/unit/speedcurve-primer.test.ts
@@ -19,8 +19,8 @@ vi.mock('../../packages/script/src/runtime/composables/useScript', () => ({
useScript: vi.fn(() => ({ proxy: {}, status: 'awaitingLoad' })),
}))
-vi.mock('@speedcurve/lux/dist/lux-snippet.js?raw', () => ({
- default: '(function(){window.LUX=window.LUX||{};window.LUX.snippetVersion="2.0.0";})()',
+vi.mock('#build/nuxt-scripts-speedcurve-snippet', () => ({
+ luxSnippetSource: '(function(){window.LUX=window.LUX||{};window.LUX.snippetVersion="2.0.0";})()',
}))
describe('useScriptSpeedCurve primer injection', () => {
diff --git a/vitest.config.ts b/vitest.config.ts
index 2ceb90e56..0e84acbfc 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -21,6 +21,15 @@ export default defineConfig({
}),
// utils folders as *.test.ts in either test/unit or in src/**/*.test.ts
defineProject({
+ resolve: {
+ alias: {
+ // Virtual emitted by the Nuxt module at build time; unit tests
+ // mock it via `vi.mock('#build/nuxt-scripts-speedcurve-snippet')`,
+ // but the import must first resolve to *something* the bundler
+ // accepts. The alias points at an empty placeholder.
+ '#build/nuxt-scripts-speedcurve-snippet': new URL('./test/unit/__mocks__/empty.ts', import.meta.url).pathname,
+ },
+ },
test: {
name: 'unit',
environment: 'node',