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 rightWhat happens if it isn'tWho handles it
Target URL passes the safeExternalUrl allowlistMissing = 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.vue each 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?)

NameTypeDefaultDescription
options.delaynumber3000Countdown duration in milliseconds (see TIME-001). On zero it runs location.href = url; 0 or negative redirects right after mount
options.fallbackstring'/'Target when the query holds no valid http(s) external link (also passed through safeExternalUrl)
options.queryKeystring'url'Which query parameter to read
options.seo{ title?: string; description?: string }built-in copyOverride the default SEO copy. ⚠️ robots: 'noindex' and schemaOrg: false are pinned and not overridable

Return value

NameTypeDescription
urlstringThe allowlist-validated target; equals fallback when the input is rejected
remainingRef<number>Remaining milliseconds
secondsComputedRef<number>Remaining whole seconds (rounded up), ready for "redirecting in N s" copy
go() => voidRedirect now (wire it to a "Go now" button)
cancel() => voidCancel 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

  • delay is milliseconds, not seconds. Use the returned seconds (already rounded up) for the countdown copy instead of dividing delay yourself.
  • robots / schemaOrg are 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.
  • fallback also 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.