Serverless Link Shortener with Click Analytics in Nuxt 4 + TypeScript + Firebase

Build a production-grade short link service with 302 redirects, atomic counters, and privacy-friendly click analytics using Firebase Functions v2 and Firestore.
Tags: Nuxt 4, TypeScript, Firebase, Cloud Functions v2, Firestore
Time to read: 12 min
What’s new and shiny:
- Cloud Functions for Firebase v2 on Cloud Run (faster cold starts, concurrency) plus Runtime Config Params and Secrets via functions/params
- Nuxt 4 stable developer experience with TypeScript-first
We’ll build:
- A Nuxt 4 admin UI to create/manage short links
- A Firebase Functions v2 HTTP endpoint that increments counters and redirects with 302
- Privacy-friendly click analytics (UA, referer, Accept-Language, salted IP hash)
- Firebase Hosting rewrite that routes /r/:slug to the redirect function
Result: https://your-domain/r/docs → 302 → https://nuxt.com/docs and the click is tracked.
Prerequisites
- Node.js 20+
- Firebase CLI (firebase-tools) v13+ installed globally
- A Firebase project created
- Basic familiarity with Nuxt, TypeScript, and Firebase
Project structure
We’ll keep Nuxt in the repo root, Cloud Functions in functions/, and deploy both to Firebase Hosting/Functions.
- nuxt-app (root)
- nuxt.config.ts
- plugins/firebase.client.ts
- components/LinkForm.vue
- pages/index.vue
- .env
- firebase.json
- firestore.rules
- functions/
- package.json
- tsconfig.json
- src/index.ts
1) Scaffold Nuxt 4
npx nuxi init nuxt-link-shortener
cd nuxt-link-shortener
npm install
Run dev once:
npm run dev
Visit http://localhost:3000 to confirm it works.
2) Add Firebase Web SDK (modular v10)
npm i firebase
3) Configure Nuxt (SPA + runtime config)
We’ll ship the admin UI as a static SPA and use Firebase Hosting rewrites for the redirect function.
nuxt.config.ts:
export default defineNuxtConfig({
ssr: false,
typescript: { strict: true },
runtimeConfig: {
public: {
firebase: {
apiKey: process.env.NUXT_PUBLIC_FIREBASE_API_KEY!,
authDomain: process.env.NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN!,
projectId: process.env.NUXT_PUBLIC_FIREBASE_PROJECT_ID!,
appId: process.env.NUXT_PUBLIC_FIREBASE_APP_ID!,
measurementId: process.env.NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID!,
storageBucket: process.env.NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET!,
messagingSenderId: process.env.NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID!,
},
functionsRegion: process.env.NUXT_PUBLIC_FUNCTIONS_REGION || 'us-central1',
},
},
})
Add .env (fill from Firebase console):
NUXT_PUBLIC_FIREBASE_API_KEY=your-api-key
NUXT_PUBLIC_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
NUXT_PUBLIC_FIREBASE_PROJECT_ID=your-project
NUXT_PUBLIC_FIREBASE_APP_ID=1:xxxxxxxxxxxx:web:xxxxxxxxxxxxxx
NUXT_PUBLIC_FIREBASE_MEASUREMENT_ID=G-XXXXXXXXXX
NUXT_PUBLIC_FIREBASE_STORAGE_BUCKET=your-project.appspot.com
NUXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=xxxxxxxxxxxx
NUXT_PUBLIC_FUNCTIONS_REGION=us-central1
4) Firebase plugin for Nuxt
plugins/firebase.client.ts:
import { initializeApp, getApps, getApp, type FirebaseApp } from 'firebase/app'
import { getFirestore } from 'firebase/firestore'
import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth'
import { getFunctions } from 'firebase/functions'
import { getAnalytics, isSupported } from 'firebase/analytics'
export default defineNuxtPlugin(async () => {
const config = useRuntimeConfig()
const fb = config.public.firebase
let app: FirebaseApp
if (!getApps().length) app = initializeApp(fb)
else app = getApp()
const analyticsSupported = await isSupported().catch(() => false)
if (analyticsSupported) getAnalytics(app)
const auth = getAuth(app)
const db = getFirestore(app)
const functions = getFunctions(app, config.public.functionsRegion)
return {
provide: {
firebase: {
app,
auth,
db,
functions,
signInWithGoogle: () => signInWithPopup(auth, new GoogleAuthProvider()),
},
},
}
})
5) Admin UI: create + list links
components/LinkForm.vue:
<template>
<form @submit.prevent="submit" style="display:grid; gap:8px; max-width:720px">
<div>
<label>Slug</label>
<input v-model="slug" placeholder="e.g. docs" required pattern="[a-z0-9\\-]{3,64}" />
<small>Lowercase letters, numbers, and dashes (3–64 chars).</small>
</div>
<div>
<label>Destination URL</label>
<input v-model="url" placeholder="https://example.com" required />
</div>
<button :disabled="loading">{{ loading ? 'Creating…' : 'Create' }}</button>
<p v-if="error" style="color:#c00">{{ error }}</p>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { httpsCallable } from 'firebase/functions'
const { $firebase } = useNuxtApp()
const slug = ref('')
const url = ref('')
const loading = ref(false)
const error = ref('')
const emit = defineEmits<{ (e: 'created'): void }>()
const submit = async () => {
error.value = ''
loading.value = true
try {
const createLink = httpsCallable($firebase.functions, 'createLink')
await createLink({ slug: slug.value, url: url.value })
slug.value = ''
url.value = ''
emit('created')
} catch (e: any) {
error.value = e?.message ?? String(e)
} finally {
loading.value = false
}
}
</script>
pages/index.vue:
<template>
<main style="margin:2rem auto; max-width:960px; padding:0 1rem">
<h1>Serverless Link Shortener</h1>
<section style="margin:1rem 0; display:flex; gap:.5rem; align-items:center">
<button v-if="!user" @click="signIn">Sign in with Google</button>
<template v-else>
<span>Signed in as {{ user.email }}</span>
<button @click="signOut">Sign out</button>
</template>
</section>
<section v-if="user" style="margin:2rem 0">
<h2>Create a short link</h2>
<LinkForm @created="reload" />
</section>
<section style="margin:2rem 0">
<h2>Links</h2>
<p v-if="loading">Loading…</p>
<ul v-else style="display:grid; gap:.5rem; padding:0">
<li v-for="link in links" :key="link.slug" style="list-style:none">
<code>{{ origin }}/r/{{ link.slug }}</code>
→ <a :href="link.destination" target="_blank" rel="noreferrer">{{ link.destination }}</a>
• {{ link.clicksTotal ?? 0 }} clicks
</li>
</ul>
</section>
</main>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import LinkForm from '~/components/LinkForm.vue'
import { onAuthStateChanged, signOut as fbSignOut } from 'firebase/auth'
import { collection, getDocs, orderBy, query, limit } from 'firebase/firestore'
type Link = {
slug: string
destination: string
clicksTotal: number
}
const { $firebase } = useNuxtApp()
const user = ref<null | { email: string }>(null)
const links = ref<Link[]>([])
const loading = ref(false)
const origin = computed(() => (typeof window !== 'undefined' ? window.location.origin : ''))
const signIn = async () => {
await $firebase.signInWithGoogle()
}
const signOut = async () => {
await fbSignOut($firebase.auth)
}
const reload = async () => {
loading.value = true
try {
const q = query(collection($firebase.db, 'links'), orderBy('createdAt', 'desc'), limit(50))
const snap = await getDocs(q)
links.value = snap.docs.map((d) => ({ slug: d.id, ...(d.data() as any) }))
} finally {
loading.value = false
}
}
onMounted(() => {
onAuthStateChanged($firebase.auth, (u) => {
user.value = u ? { email: u.email || '' } : null
})
reload()
})
</script>
6) Initialize Firebase Hosting + Functions v2
firebase login
firebase init
- Choose Hosting (configure for an existing project), Functions, and Firestore Rules
- When asked for Functions, select TypeScript and Node 20, and opt into Functions v2
- Set Hosting public directory to .output/public (we’ll generate it)
- Choose to set a SPA fallback (rewrite to /index.html)
Your firebase.json should look like:
{
"functions": { "source": "functions" },
"hosting": {
"public": ".output/public",
"ignore": ["firebase.json", "**/.*", "**/node_modules/**"],
"rewrites": [
{ "source": "/r/**", "function": { "functionId": "redirect", "region": "us-central1" } },
{ "source": "**", "destination": "/index.html" }
]
}
}
7) Functions v2: redirect + createLink (with new Runtime Params/Secrets)
We’ll use:
- onRequest for the redirect handler
- onCall for admin-only link creation
- functions/params defineString + defineSecret (newer API) to manage admin allowlist and a click hashing salt
functions/package.json (generated by init; ensure dependencies are recent):
{
"engines": { "node": "20" },
"type": "module",
"main": "lib/index.js",
"scripts": {
"build": "tsc",
"serve": "npm run build && firebase emulators:start --only functions,hosting,firestore",
"deploy": "firebase deploy --only functions",
"lint": "eslint --ext .ts src"
},
"dependencies": {
"firebase-admin": "^12.6.0",
"firebase-functions": "^5.1.0"
},
"devDependencies": {
"typescript": "^5.6.3"
}
}
functions/tsconfig.json (minimal):
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "lib",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"compileOnSave": true,
"include": ["src"]
}
functions/src/index.ts:
import { onRequest, onCall, HttpsError } from 'firebase-functions/v2/https'
import { defineString, defineSecret } from 'firebase-functions/params'
import { initializeApp } from 'firebase-admin/app'
import { getFirestore, FieldValue } from 'firebase-admin/firestore'
import { createHash } from 'crypto'
initializeApp()
const db = getFirestore()
// New Runtime Params / Secrets (firebase functions:params:set ...)
const ADMIN_EMAILS = defineString('SHORTENER_ADMINS') // comma-separated list
const CLICK_SALT = defineSecret('CLICK_SALT') // random string to salt IP hashes
export const createLink = onCall(
{ region: 'us-central1', secrets: [CLICK_SALT] },
async (request) => {
const uid = request.auth?.uid
const email = (request.auth?.token.email as string | undefined)?.toLowerCase()
if (!uid || !email) {
throw new HttpsError('unauthenticated', 'Sign in required.')
}
const allowed = (ADMIN_EMAILS.value() || '')
.split(',')
.map((e) => e.trim().toLowerCase())
.filter(Boolean)
if (!allowed.includes(email)) {
throw new HttpsError('permission-denied', 'You are not allowed to create links.')
}
const { slug, url } = (request.data || {}) as { slug?: string; url?: string }
if (!slug || !url) throw new HttpsError('invalid-argument', 'Missing slug or url.')
const normalizedSlug = String(slug).trim().toLowerCase()
if (!/^[a-z0-9-]{3,64}$/.test(normalizedSlug)) {
throw new HttpsError('invalid-argument', 'Slug must be 3–64 chars, a-z, 0-9, dashes.')
}
let destination: string
try {
const u = new URL(url)
if (!['http:', 'https:'].includes(u.protocol)) throw new Error('Invalid protocol')
destination = u.toString()
} catch {
throw new HttpsError('invalid-argument', 'URL must be a valid http(s) URL.')
}
const docRef = db.collection('links').doc(normalizedSlug)
const existing = await docRef.get()
if (existing.exists) throw new HttpsError('already-exists', 'Slug already in use.')
await docRef.set({
destination,
active: true,
createdAt: FieldValue.serverTimestamp(),
createdBy: { uid, email },
clicksTotal: 0,
lastClickedAt: null,
})
return { ok: true, slug: normalizedSlug }
}
)
export const redirect = onRequest(
{ region: 'us-central1', secrets: [CLICK_SALT] },
async (req, res) => {
// Expect /r/<slug>
const match = req.path.match(/^\/r\/([^/?#]+)$/)
const slug = match?.[1]
if (!slug) {
res.status(404).send('Not found')
return
}
const docRef = db.collection('links').doc(slug)
const snap = await docRef.get()
if (!snap.exists) {
res.status(404).send('Not found')
return
}
const data = snap.data() as any
if (!data.active || !data.destination) {
res.status(410).send('Gone')
return
}
// Collect privacy-friendly analytics
const ua = (req.headers['user-agent'] as string) || ''
const ref = (req.headers['referer'] as string) || (req.headers['referrer'] as string) || ''
const al = (req.headers['accept-language'] as string) || ''
const xff = Array.isArray(req.headers['x-forwarded-for'])
? req.headers['x-forwarded-for'][0]
: (req.headers['x-forwarded-for'] as string | undefined)
const ip =
(xff ? xff.split(',')[0].trim() : undefined) ||
req.socket.remoteAddress ||
'0.0.0.0'
const salt = CLICK_SALT.value()
const ipHash = createHash('sha256').update(ip + (salt || '')).digest('hex')
await Promise.all([
// Atomic increment + last-click timestamp
docRef.update({
clicksTotal: FieldValue.increment(1),
lastClickedAt: FieldValue.serverTimestamp(),
}),
// Append click event
docRef.collection('clicks').add({
ts: FieldValue.serverTimestamp(),
ua,
ref,
al,
ipHash,
}),
])
res.set('Cache-Control', 'no-store')
res.redirect(302, data.destination)
}
)
Set Runtime Params and Secret:
firebase functions:params:set SHORTENER_ADMINS="you@example.com, teammate@company.com"
firebase functions:params:set CLICK_SALT="paste-a-long-random-string"
Tip: generate a salt with e.g. openssl rand -hex 32.
8) Firestore Security Rules
- Public can read links (to list on the page)
- No public writes (only functions write)
- Click event reads only for signed-in users (optional; you can lock further if desired)
firestore.rules:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /links/{slug} {
allow read: if true; // public read
allow write: if false; // only via Functions Admin SDK
match /clicks/{eventId} {
allow read: if request.auth != null; // optional: only signed-in can read event docs
allow write: if false; // only via redirect function
}
}
}
}
Deploy rules:
firebase deploy --only firestore:rules
9) Local test (optional, recommended)
You can emulate Functions, Hosting, and Firestore:
# Build functions once
cd functions && npm run build && cd ..
# Build Nuxt static
npx nuxi generate
# Start emulators
firebase emulators:start --only functions,hosting,firestore
Visit the emulator Hosting URL. Sign in, create a link, then open /r/
10) Build and deploy
Generate static files for Nuxt SPA:
npx nuxi generate
Deploy Functions + Hosting:
firebase deploy
Verify:
- Admin UI: https://your-project.web.app
- Redirects: https://your-project.web.app/r/your-slug
If you use a custom domain, replace the host accordingly.
Notes on scale and accuracy
- Firestore document write rate: the single doc counter uses FieldValue.increment and is safe for moderate traffic. For >1 write/sec sustained to the same doc, consider sharded counters or per-day rollups.
- Caching: we set Cache-Control: no-store on the redirect response to avoid losing clicks in cached responses.
- Privacy: we hash IPs with a server-side secret; never store raw IPs. You can also strip user-agent or referer if you need stricter privacy guarantees.
- Security: creation is via onCall and admin-allowlist using the new functions/params API (no legacy config). Keep CLICK_SALT secret rotated if needed.
Bonus: minimal “Top referrers” query (optional)
You can aggregate referrers client-side for a single link by reading the latest N click events:
import { collection, getDocs, orderBy, limit, query } from 'firebase/firestore'
async function topReferrers(slug: string) {
const { $firebase } = useNuxtApp()
const q = query(
collection($firebase.db, `links/${slug}/clicks`),
orderBy('ts', 'desc'),
limit(200)
)
const snap = await getDocs(q)
const counts = new Map<string, number>()
for (const doc of snap.docs) {
const ref = (doc.data().ref as string) || '(direct)'
counts.set(ref, (counts.get(ref) || 0) + 1)
}
return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10)
}
For heavier traffic, prefer backend aggregation with scheduled functions or BigQuery export.
Conclusion
You now have a modern, serverless link shortener with:
- Nuxt 4 + TypeScript admin UI
- Firebase Functions v2 redirect handler with atomic counters
- Privacy-friendly analytics
- Firebase Hosting rewrites for clean /r/:slug links
- Secure creation via onCall + Runtime Params/Secrets
This stack is fast to iterate on, cheap to run, and scales effortlessly with Cloud Run-powered Functions v2.
Happy shipping!





