useExternalRedirect()
useExternalRedirect is the logic layer for an external-link interstitial page (/link?url=https://example.com): it reads the target from the query, runs it through the security allowlist, counts down and redirects, and pins the SEO settings this kind of page must get right.
An interstitial has to get three things right at once, and each of them fails invisibly:
| What must be right | What happens if it isn't | Who handles it |
|---|---|---|
Target URL passes the safeExternalUrl allowlist | Missing = DOM XSS via javascript: assigned to location.href | ✅ built into this composable |
robots: 'noindex' | Missing = the interstitial gets indexed | ✅ built in (not overridable) |
The page declares defineRouteRules({ ssr: false }) | Missing = a query-driven page gets ISR-cached as the first visitor's target | 🔴 you write this (see below) |
Consolidated from the
pages/link.vueeach of the 9 appliers kept (framework#Y-9). The starter copy was revised 12 times while applier copies only followed the first 2 — patches don't propagate on their own, so the logic moves into the framework and the markup stays with the applier.
🔴 The page must declare ssr: false itself
This one is out of the composable's reach: defineRouteRules is a Nuxt compile-time macro and can only live in the page file.
<!-- pages/link.vue -->
<script setup lang="ts">
defineRouteRules({ ssr: false }) // 🔴 not optional
definePageMeta({ layout: false })
const { url, seconds } = useExternalRedirect()
</script>
<template>
<p>Heading to: <a :href="url">{{ url }}</a></p>
<p>Redirecting in <strong>{{ seconds }}</strong>s</p>
</template>
What goes wrong without it: the page's entire content is determined by ?url=, so once it is ISR / SWR cached, every later visitor receives the first visitor's target. That is not just a display bug — it hands user A's destination to user B.
Safety net: in dev, the composable console.warns by name when it executes on the server (silent in production). But it only appears once you actually hit that path — don't rely on it as the gate.
Parameters
useExternalRedirect(options?)
| Name | Type | Default | Description |
|---|---|---|---|
options.delay | number | 3000 | Countdown duration in milliseconds (see TIME-001). On zero it runs location.href = url; 0 or negative redirects right after mount |
options.fallback | string | '/' | Target when the query holds no valid http(s) external link (also passed through safeExternalUrl) |
options.queryKey | string | 'url' | Which query parameter to read |
options.seo | { title?: string; description?: string } | built-in copy | Override the default SEO copy. ⚠️ robots: 'noindex' and schemaOrg: false are pinned and not overridable |
Return value
| Name | Type | Description |
|---|---|---|
url | string | The allowlist-validated target; equals fallback when the input is rejected |
remaining | Ref<number> | Remaining milliseconds |
seconds | ComputedRef<number> | Remaining whole seconds (rounded up), ready for "redirecting in N s" copy |
go | () => void | Redirect now (wire it to a "Go now" button) |
cancel | () => void | Cancel the auto-redirect (stops the timer; url stays clickable) |
Examples
<!-- Example 1: minimal interstitial -->
<script setup lang="ts">
defineRouteRules({ ssr: false })
const { url, seconds } = useExternalRedirect()
</script>
<template>
<a :href="url">{{ url }}</a>
<p>Redirecting in {{ seconds }}s</p>
</template>
<!-- Example 2: give the user both exits -->
<script setup lang="ts">
defineRouteRules({ ssr: false })
const { url, seconds, go, cancel } = useExternalRedirect({ delay: 5000 })
</script>
<template>
<p>Heading to {{ url }} in {{ seconds }}s</p>
<SwButton @click="go">Go now</SwButton>
<SwButton variant="ghost" @click="cancel">Stay here</SwButton>
</template>
// Example 3: tune the countdown / fallback / query key / copy
useExternalRedirect({
delay: 5000, // milliseconds
fallback: '/home', // where to go when there is no valid external link
queryKey: 'target', // read ?target= instead of ?url=
seo: { title: 'Redirecting…', description: 'Leaving the site' },
})
Notes
delayis milliseconds, not seconds. Use the returnedseconds(already rounded up) for the countdown copy instead of dividingdelayyourself.robots/schemaOrgare deliberately not overridable: an indexed interstitial, or one carrying JSON-LD, are the textbook mistakes for this page type — there is no legitimate counter-case.fallbackalso goes through the allowlist, so an off-site value is constrained the same way; use a relative path to stay on your own site.- The page markup (styling, branding, the "Go now" button) is not provided by framework — sites differ too much visually, so only the logic is consolidated.
Related
- safeExternalUrl — the underlying external-link allowlist check
- useSeo — used internally to set
robotsand the copy - Rendering strategies · why an interstitial needs
ssr: false