November 19, 202512 min

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.

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

cover

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()),
      },
    },
  }
})

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" }
    ]
  }
}

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

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/ and confirm redirect + counter increments.

10) Build and deploy

Generate static files for Nuxt SPA:

npx nuxi generate

Deploy Functions + Hosting:

firebase deploy

Verify:

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!