Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions docs/content/docs/1.guides/2.custom-script-patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
---

title: Custom Script Patterns
description: Common integration patterns for loading and wrapping third-party scripts with useScript().

---

When the [Script Registry](/scripts) doesn't cover your use case, you can build custom integrations with [`useScript()`{lang="ts"}](/docs/api/use-script). This guide walks through the most common patterns, from basic loading to strongly-typed API wrappers.

## Basic Integration

The simplest pattern wraps a third-party IIFE script and waits for its global to become available.

```ts [composables/useMyAnalytics.ts]
export function useMyAnalytics() {
return useScript<{ myAnalytics: { send: (event: string) => void } }>(
'https://cdn.example.com/analytics.iife.js',
{
// Load only after Nuxt is fully hydrated (default behavior)
trigger: 'onNuxtReady',
use: () => ({ myAnalytics: window.myAnalytics }),
},
)
}
```

The `use` option tells Nuxt Scripts how to resolve the API from the global scope once the script loads. See [Understanding proxied functions](/docs/guides/key-concepts) for how the returned object stays SSR-safe before the script is ready.

You can then call methods through the proxy without worrying about load order:

```vue [app.vue]
<script setup lang="ts">
const { myAnalytics } = useMyAnalytics()

// Safe to call immediately - queued until the script loads
myAnalytics.send('page_view')
</script>
```

If you need the return value of a function call, await `onLoaded` instead:

```ts
const { onLoaded } = useMyAnalytics()

onLoaded(({ myAnalytics }) => {
const result = myAnalytics.send('page_view')
console.log(result)
})
```

## API Wrapping

For scripts with a larger API surface, define a typed interface and expose only the methods you need. This pattern mirrors how registry scripts like `useScriptSegment` wrap third-party APIs.

```ts [composables/useMyChat.ts]
interface MyChatApi {
open: () => void
close: () => void
sendMessage: (text: string) => void
onMessage: (callback: (msg: { text: string, from: string }) => void) => void
}

declare global {
interface Window {
MyChat: MyChatApi
}
}

export function useMyChat(token: string) {
return useScript<MyChatApi>(
{
src: `https://cdn.example.com/chat-widget.js?token=${token}`,
defer: true,
},
{
use: () => window.MyChat,
// Initialize the widget after the script loads
stub: import.meta.client
? undefined
: ({ fn }) => {
// Return a no-op during SSR to prevent errors
return fn === 'open' || fn === 'close' ? () => {} : undefined
},
},
)
}
```

Using the wrapper in a component:

```vue [components/ChatButton.vue]
<script setup lang="ts">
const { open, onLoaded } = useMyChat('your-token')

// Subscribe to messages after the script loads
onLoaded(({ onMessage }) => {
onMessage((msg) => {
console.log(`New message from ${msg.from}: ${msg.text}`)
})
})
</script>

<template>
<button @click="open()">
Open Chat
</button>
</template>
```

::callout{icon="i-heroicons-light-bulb" color="blue"}
Because [`useScript()`{lang="ts"}](/docs/api/use-script) is a singleton, calling `useMyChat()` in multiple components returns the same instance. You don't need to worry about loading the script twice.
::

## Consent Management

Scripts that set tracking cookies need to wait for user consent. Use [`useScriptTriggerConsent()`{lang="ts"}](/docs/api/use-script-trigger-consent) to coordinate loading with a consent banner.

For full details on consent workflows, see the [Consent Management](/docs/guides/consent) guide. Here is the pattern applied to a custom script:

```ts [composables/useCustomTracker.ts]
export function useCustomTracker(consent: Ref<boolean>) {
const consentTrigger = useScriptTriggerConsent({ consent })

return useScript<{ tracker: { track: (event: string) => void } }>(
'https://cdn.example.com/tracker.js',
{
trigger: consentTrigger,
use: () => ({ tracker: window.tracker }),
},
)
}
```

```vue [app.vue]
<script setup lang="ts">
const hasConsent = ref(false)
const { tracker } = useCustomTracker(hasConsent)

// This call is queued - it only fires after consent is granted AND the script loads
tracker.track('page_view')

function acceptCookies() {
hasConsent.value = true
}
</script>

<template>
<button @click="acceptCookies">
Accept Cookies
</button>
</template>
```

To revoke consent and remove the script, call `remove()` on the script instance:

```ts
const { $script } = useCustomTracker(hasConsent)

function revokeCookies() {
hasConsent.value = false
$script.then(instance => instance.remove())
}
```

## Error Handling

Third-party scripts can fail to load due to network issues, ad blockers, or CSP restrictions. Use the `status` ref and `onError` callback for graceful degradation.

```ts [composables/usePaymentForm.ts]
export function usePaymentForm(publishableKey: string) {
const { $script, ...rest } = useScript<{ PaymentSDK: { init: (key: string) => void } }>(
'https://cdn.example.com/payment-sdk.js',
{
use: () => ({ PaymentSDK: window.PaymentSDK }),
},
)

const error = ref<string | null>(null)

$script.catch(() => {
error.value = 'Payment form unavailable. Please try again later.'
})

return { ...rest, $script, error }
}
```

```vue [components/PaymentForm.vue]
<script setup lang="ts">
const { status, error, PaymentSDK } = usePaymentForm('pk_live_xxx')
</script>

<template>
<div>
<div v-if="status === 'loading'">
Loading payment form...
</div>
<div v-else-if="error">
{{ error }}
<p>Alternatively, <a href="/contact">contact us</a> to complete your order.</p>
</div>
<div v-else>
<!-- Payment form renders here -->
</div>
</div>
</template>
```

::callout{icon="i-heroicons-light-bulb" color="blue"}
Proxied function calls are silently dropped when a script fails to load. This means your app won't throw errors, but the functionality simply won't be available. Always provide a fallback UI for critical features.
::

## Registry Contribution Checklist

If your custom integration is useful to others, consider contributing it to the [Script Registry](/scripts). Each registry entry follows a consistent structure.

### Required Files

For a script called `my-service`, create the following files:

| File | Purpose |
| --- | --- |
| `src/runtime/registry/my-service.ts` | Composable using `useRegistryScript()` |
| `src/runtime/registry/schemas/my-service.ts` | Valibot schema for options validation |
| `src/runtime/components/ScriptMyService.vue` | (Optional) Facade component |
| `docs/content/scripts/analytics/my-service.md` | Documentation page |

### Composable Structure

Registry composables use `useRegistryScript()` instead of `useScript()` directly. This helper handles runtime config, schema validation, and the initialization lifecycle:

```ts [src/runtime/registry/my-service.ts]
import type { RegistryScriptInput } from '#nuxt-scripts/types'
import { useRegistryScript } from '../utils'
import { MyServiceOptions } from './schemas'

export type MyServiceInput = RegistryScriptInput<typeof MyServiceOptions>

export interface MyServiceApi {
track: (event: string, properties?: Record<string, any>) => void
}

export function useScriptMyService<T extends MyServiceApi>(
_options?: MyServiceInput,
) {
return useRegistryScript<T, typeof MyServiceOptions>('my-service', options => ({
scriptInput: {
src: `https://cdn.example.com/sdk.js?key=${options?.apiKey}`,
},
schema: import.meta.dev ? MyServiceOptions : undefined,
scriptOptions: {
use: () => window.myService as T,
},
}), _options)
}
```

### Contribution Steps

1. **Check for existing requests** -- Search the [issues](https://github.com/nuxt/scripts/issues) for your script. If there's an open request (like [#604](https://github.com/nuxt/scripts/issues/604)), comment that you're working on it.
2. **Create the schema** -- Define a [Valibot](https://valibot.dev/) schema in `src/runtime/registry/schemas/`. Validate required fields like API keys or token lengths.
3. **Implement the composable** -- Follow the structure above. Use `clientInit` for any global setup code that needs to run before the script loads.
4. **Write documentation** -- Add a docs page under `docs/content/scripts/` in the appropriate category. Include usage examples and a list of available options.
5. **Add tests** -- Registry scripts have end-to-end tests in `test/`. Check an existing test like `google-analytics.test.ts` for the pattern.
6. **Open a PR** -- Reference the issue, describe the integration, and include a screenshot of the docs page if applicable.
6 changes: 6 additions & 0 deletions docs/content/docs/3.api/1.use-script.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,9 @@ watch(() => route.path, () => reload())
::callout{icon="i-heroicons-light-bulb" color="blue"}
Many third-party scripts have their own SPA support (e.g., `_iub.cs.api.activateSnippets()`{lang="ts"} for iubenda). Check the script's documentation before using `reload()`{lang="ts"} - their built-in methods are usually more efficient.
::

## See Also

- [Custom Script Patterns](/docs/guides/custom-script-patterns) - Common integration patterns for wrapping third-party scripts, including API typing, consent management, and error handling.
- [Key Concepts](/docs/guides/key-concepts) - How proxied functions and script singletons work.
- [Registry Scripts](/docs/guides/registry-scripts) - Pre-configured integrations for popular third-party scripts.
Loading