Vue.js · Dashboard Tutorial

Build a Tech Stack Detection Dashboard in Vue.js with StackPeek API

Published March 29, 2026 · 14 min read

Competitive intelligence starts with knowing what your competitors are running. Lead qualification gets sharper when you can see a prospect's technology choices before the first call. But raw API data is useless without a way to explore it. What you need is a dashboard — something that lets your team scan URLs, compare stacks side by side, and export the results without writing a single line of code each time.

This tutorial walks through building that dashboard in Vue.js 3 using the Composition API. By the end, you will have a reactive single-page application that scans websites via the StackPeek API, displays categorized technology results, compares multiple competitors in a grid, caches results with Pinia, visualizes adoption data with Chart.js, and exports everything to CSV. All in under 600 lines of Vue code.

Why a Tech Stack Dashboard Matters

There are two audiences for a tool like this. Sales teams use it to qualify leads: if a prospect runs Shopify but has no email marketing tool, that is a warm lead for anyone selling Klaviyo alternatives. Product teams use it for competitive intelligence: knowing that 70% of your competitors ship on Next.js while you are still on a PHP monolith is a useful data point for your next architecture review.

A dashboard makes both workflows self-service. Nobody has to ask engineering to run a script. Nobody has to install a browser extension and manually record what they see. Paste a URL, get structured results, compare, export, move on.

Quick Start: Your First API Call from Vue

Before building the full dashboard, let's confirm the StackPeek API works from a Vue component. This is the minimal version — a single <script setup> block that fetches technologies for a hardcoded URL:

<!-- TechProbe.vue -->
<script setup>
import { ref, onMounted } from 'vue'

const API_BASE = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect'
const technologies = ref([])
const loading = ref(false)

onMounted(async () => {
  loading.value = true
  const res = await fetch(`${API_BASE}?url=https://shopify.com`)
  const data = await res.json()
  technologies.value = data.technologies
  loading.value = false
})
</script>

<template>
  <div v-if="loading">Scanning...</div>
  <ul v-else>
    <li v-for="tech in technologies" :key="tech.name">
      {{ tech.name }}  {{ tech.category }} ({{ Math.round(tech.confidence * 100) }}%)
    </li>
  </ul>
</template>

That is the entire feedback loop: ref() for reactive state, fetch() for the API call, v-for to render the list. No API key needed on the free tier — 100 scans per day at zero cost. Everything that follows is building on this foundation.

Free tier: 100 scans per day, no API key, no signup. For a paid plan with 5,000 scans/month, the Starter tier is $9/month — see stackpeek.web.app for pricing.

Vue 3 Composition API: Reactive State, Loading, and Error Handling

A production dashboard needs more than a happy path. Users will paste malformed URLs. The API will occasionally time out. Your UI needs to communicate what is happening at every step. Here is a composable that encapsulates the scan logic with proper error handling:

// composables/useStackPeek.js
import { ref, computed } from 'vue'

const API_BASE = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect'

export function useStackPeek() {
  const technologies = ref([])
  const loading = ref(false)
  const error = ref(null)
  const scannedUrl = ref('')

  const categories = computed(() => {
    const map = {}
    technologies.value.forEach(t => {
      if (!map[t.category]) map[t.category] = []
      map[t.category].push(t)
    })
    return map
  })

  async function scan(url) {
    if (!url || !url.trim()) {
      error.value = 'Please enter a URL'
      return
    }
    loading.value = true
    error.value = null
    scannedUrl.value = url

    try {
      const target = url.startsWith('http') ? url : `https://${url}`
      const res = await fetch(
        `${API_BASE}?url=${encodeURIComponent(target)}`,
        { signal: AbortSignal.timeout(15000) }
      )
      if (!res.ok) throw new Error(`API returned ${res.status}`)
      const data = await res.json()
      technologies.value = data.technologies || []
    } catch (e) {
      error.value = e.name === 'TimeoutError'
        ? 'Scan timed out — try again'
        : `Scan failed: ${e.message}`
      technologies.value = []
    } finally {
      loading.value = false
    }
  }

  return { technologies, categories, loading, error, scannedUrl, scan }
}

The categories computed property automatically groups technologies by category (framework, analytics, cdn, payments, etc.) whenever the raw array changes. This drives the categorized grid in the template without any manual sorting. The AbortSignal.timeout prevents the UI from hanging if a scan takes too long.

Building the URL Scanner Component

The scanner is the core interaction. A text input with v-model, a submit handler, and conditional rendering for loading, error, and result states:

<!-- UrlScanner.vue -->
<script setup>
import { ref } from 'vue'
import { useStackPeek } from '@/composables/useStackPeek'

const urlInput = ref('')
const { technologies, categories, loading, error, scan } = useStackPeek()

function handleScan() {
  scan(urlInput.value)
}
</script>

<template>
  <form @submit.prevent="handleScan" class="scanner-form">
    <input
      v-model="urlInput"
      type="text"
      placeholder="Enter a URL — e.g. stripe.com"
      :disabled="loading"
    />
    <button type="submit" :disabled="loading">
      {{ loading ? 'Scanning...' : 'Scan' }}
    </button>
  </form>

  <div v-if="error" class="error-msg">{{ error }}</div>

  <div v-if="technologies.length" class="results-grid">
    <div v-for="(techs, category) in categories" :key="category" class="category-card">
      <h3>{{ category }}</h3>
      <div v-for="tech in techs" :key="tech.name" class="tech-badge">
        <span class="tech-name">{{ tech.name }}</span>
        <span class="tech-confidence">{{ Math.round(tech.confidence * 100) }}%</span>
      </div>
    </div>
  </div>
</template>

The v-model on the input gives you two-way binding — the urlInput ref always reflects what the user has typed. The @submit.prevent stops the default form submission and calls handleScan instead. The :disabled binding on both the input and button prevents double-submissions while a scan is in flight.

The results grid iterates over the categories computed property. Each category gets its own card with a header (framework, analytics, cdn) and a list of technology badges showing the name and confidence percentage. This grouped view is significantly more useful than a flat list — at a glance you can see a site's framework choices, analytics stack, and payment provider without scrolling.

Batch Scanning: Compare Competitor Stacks Side by Side

Scanning one URL is useful. Scanning five competitors and comparing their stacks is where a dashboard becomes genuinely valuable. Here is a composable for batch scanning with concurrency control:

// composables/useBatchScan.js
import { ref, reactive } from 'vue'

const API_BASE = 'https://us-central1-todd-agent-prod.cloudfunctions.net/stackpeekApi/api/v1/detect'

export function useBatchScan() {
  const results = ref(new Map())  // url -> { technologies, error, loading }
  const scanning = ref(false)
  const progress = reactive({ done: 0, total: 0 })

  async function scanOne(url) {
    const target = url.startsWith('http') ? url : `https://${url}`
    try {
      const res = await fetch(
        `${API_BASE}?url=${encodeURIComponent(target)}`,
        { signal: AbortSignal.timeout(15000) }
      )
      const data = await res.json()
      results.value.set(url, { technologies: data.technologies || [], error: null })
    } catch (e) {
      results.value.set(url, { technologies: [], error: e.message })
    }
    progress.done++
  }

  async function scanBatch(urls, concurrency = 3) {
    scanning.value = true
    progress.done = 0
    progress.total = urls.length
    results.value = new Map()

    // Process in chunks to respect rate limits
    for (let i = 0; i < urls.length; i += concurrency) {
      const chunk = urls.slice(i, i + concurrency)
      await Promise.allSettled(chunk.map(url => scanOne(url)))
    }
    scanning.value = false
  }

  return { results, scanning, progress, scanBatch }
}

The component that consumes this composable presents a textarea for pasting multiple URLs (one per line) and a comparison table that populates as each scan completes:

<!-- BatchScanner.vue -->
<script setup>
import { ref } from 'vue'
import { useBatchScan } from '@/composables/useBatchScan'

const urlText = ref('')
const { results, scanning, progress, scanBatch } = useBatchScan()

function handleBatch() {
  const urls = urlText.value.split('\n').map(u => u.trim()).filter(Boolean)
  if (urls.length) scanBatch(urls)
}
</script>

<template>
  <textarea v-model="urlText" rows="5" placeholder="Paste URLs, one per line" />
  <button @click="handleBatch" :disabled="scanning">
    {{ scanning ? `Scanning ${progress.done}/${progress.total}...` : 'Compare All' }}
  </button>

  <table v-if="results.size" class="comparison-table">
    <thead>
      <tr>
        <th>URL</th>
        <th>Technologies</th>
        <th>Count</th>
      </tr>
    </thead>
    <tbody>
      <tr v-for="[url, data] in results" :key="url">
        <td>{{ url }}</td>
        <td>{{ data.technologies.map(t => t.name).join(', ') || data.error }}</td>
        <td>{{ data.technologies.length }}</td>
      </tr>
    </tbody>
  </table>
</template>

The progress indicator updates reactively as each scan finishes. Users see "Scanning 3/5..." rather than staring at a spinner for 30 seconds wondering if anything is happening.

Pinia Store for Caching Scan Results

Every API call costs either time (on the free tier) or money (on the paid tier). If someone scans stripe.com and then navigates away and comes back, there is no reason to hit the API again. A Pinia store with optional localStorage persistence solves this:

// stores/scanCache.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

const CACHE_TTL = 7 * 24 * 60 * 60 * 1000 // 7 days

export const useScanCacheStore = defineStore('scanCache', () => {
  const cache = ref(new Map())

  const totalScans = computed(() => cache.value.size)

  function get(url) {
    const entry = cache.value.get(url)
    if (!entry) return null
    if (Date.now() - entry.timestamp > CACHE_TTL) {
      cache.value.delete(url)
      return null
    }
    return entry.data
  }

  function set(url, data) {
    cache.value.set(url, { data, timestamp: Date.now() })
  }

  function clear() {
    cache.value.clear()
  }

  return { cache, totalScans, get, set, clear }
}, {
  persist: true  // requires pinia-plugin-persistedstate
})

Then modify the composable's scan function to check the cache first:

// Inside useStackPeek.js — updated scan function
import { useScanCacheStore } from '@/stores/scanCache'

async function scan(url) {
  const cacheStore = useScanCacheStore()
  const cached = cacheStore.get(url)
  if (cached) {
    technologies.value = cached.technologies
    scannedUrl.value = url
    return  // cache hit — no API call
  }

  // ... existing fetch logic ...

  // After successful fetch:
  cacheStore.set(url, { technologies: technologies.value })
}

With pinia-plugin-persistedstate enabled, the cache survives page reloads. The 7-day TTL ensures data stays fresh without burning scans on repeated lookups. Install it with npm install pinia-plugin-persistedstate and register the plugin in your Pinia setup.

Chart.js Visualization: Technology Adoption Pie Chart

Numbers in a table are fine. A chart is better. Once you have scan results (especially from batch scanning), a doughnut chart showing category distribution gives an instant visual summary. Install Chart.js and the Vue wrapper:

npm install chart.js vue-chartjs

Then build the chart component:

<!-- TechChart.vue -->
<script setup>
import { computed } from 'vue'
import { Doughnut } from 'vue-chartjs'
import {
  Chart as ChartJS, ArcElement, Tooltip, Legend
} from 'chart.js'

ChartJS.register(ArcElement, Tooltip, Legend)

const props = defineProps({
  technologies: { type: Array, required: true }
})

const palette = [
  '#22D3EE', '#FBBF24', '#A78BFA',
  '#34D399', '#F87171', '#FB923C',
  '#60A5FA', '#E879F9'
]

const chartData = computed(() => {
  const counts = {}
  props.technologies.forEach(t => {
    counts[t.category] = (counts[t.category] || 0) + 1
  })
  const labels = Object.keys(counts)
  return {
    labels,
    datasets: [{
      data: labels.map(l => counts[l]),
      backgroundColor: labels.map((_, i) => palette[i % palette.length]),
      borderWidth: 0
    }]
  }
})

const chartOptions = {
  responsive: true,
  plugins: {
    legend: {
      position: 'bottom',
      labels: { color: '#9E9EA8', font: { family: 'Figtree' } }
    }
  }
}
</script>

<template>
  <div style="max-width: 360px; margin: 0 auto;">
    <Doughnut :data="chartData" :options="chartOptions" />
  </div>
</template>

The chartData computed property rebuilds automatically whenever props.technologies changes. Drop this component into your scanner page and pass it the technologies array — the chart renders immediately after each scan, with no manual updates needed. The doughnut format works well because tech stack data is categorical: you want to see the proportion of frameworks vs. analytics vs. CDN at a glance.

Batch visualization: For batch scans, aggregate all technologies from all scanned URLs into a single flat array before passing it to the chart. This gives you a "technology frequency across competitors" view — invaluable for spotting industry trends.

Export to CSV

Dashboards are great for exploration. Spreadsheets are great for sharing with people who don't have access to the dashboard. A CSV export function bridges the gap:

// utils/exportCsv.js
export function exportToCsv(results, filename = 'stackpeek-export.csv') {
  const rows = [
    ['URL', 'Technology', 'Category', 'Confidence']
  ]

  for (const [url, data] of results) {
    if (!data.technologies.length) {
      rows.push([url, '(none detected)', '', ''])
      continue
    }
    data.technologies.forEach(t => {
      rows.push([
        url,
        t.name,
        t.category,
        `${Math.round(t.confidence * 100)}%`
      ])
    })
  }

  const csv = rows.map(r =>
    r.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')
  ).join('\n')

  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
  const link = document.createElement('a')
  link.href = URL.createObjectURL(blob)
  link.download = filename
  link.click()
  URL.revokeObjectURL(link.href)
}

Wire it to a button in your batch scanner component:

<button @click="exportToCsv(results, 'competitor-stacks.csv')">
  Export to CSV
</button>

Each row in the exported CSV contains the URL, technology name, category, and confidence score. One URL may produce multiple rows (one per detected technology), which is the format most CRMs and spreadsheet tools expect for pivot tables and filtering. Your sales team can filter by category, sort by confidence, or pivot by technology name to see which competitors are most similar.

Putting It All Together

The complete dashboard architecture looks like this:

  1. App.vue — top-level layout with tab navigation (Single Scan / Batch Compare)
  2. UrlScanner.vue — single URL input, categorized results grid, doughnut chart
  3. BatchScanner.vue — multi-URL textarea, comparison table, aggregated chart, CSV export
  4. TechChart.vue — reusable Chart.js doughnut component
  5. composables/useStackPeek.js — single-scan logic with caching
  6. composables/useBatchScan.js — batch-scan logic with concurrency control
  7. stores/scanCache.js — Pinia store for persistent caching
  8. utils/exportCsv.js — CSV generation and download

Scaffold the project with npm create vue@latest, install pinia, pinia-plugin-persistedstate, chart.js, and vue-chartjs, and drop these files into place. The entire dashboard runs client-side — no backend needed beyond the StackPeek API itself. Deploy to Firebase Hosting or any static hosting provider.

Dependency Purpose Size
vue UI framework ~33 KB gzipped
pinia State management + caching ~2 KB gzipped
chart.js + vue-chartjs Doughnut / bar chart ~40 KB gzipped
StackPeek API Technology detection 0 KB (external)

Total client-side bundle: under 80 KB gzipped. The dashboard loads in under a second on any modern connection. No heavy build tools, no server-side rendering complexity — just Vue, a few libraries, and an API.

Start Building Your Vue Dashboard

100 free scans per day. No API key required. Copy the composables above, wire them into your Vue 3 app, and ship a tech stack dashboard this afternoon.

View Pricing & Docs →

Beyond the Basics

Once the core dashboard is working, there are a few natural extensions:

StackPeek is part of the Peek Suite — a family of developer-focused API tools. If you are building a broader competitive intelligence platform, pair this with SEOPeek for on-page SEO audits and OGPeek for social preview image generation.

Frequently Asked Questions

Do I need an API key to use StackPeek from Vue?

No. The free tier gives you 100 scans per day with zero authentication. Just make a fetch() call from your Vue component. For higher volumes, the Starter plan at $9/month provides an API key and 5,000 scans per month.

Can I use this with Nuxt 3 and server-side rendering?

Yes. The StackPeek API is a standard REST endpoint. In Nuxt 3, use useFetch() or $fetch() from a server route to call it server-side. This avoids CORS issues entirely and keeps any API keys out of the client bundle.

Does the Composition API work with Vue 2?

The <script setup> syntax and ref/reactive functions require Vue 3. If you are on Vue 2, you can use the @vue/composition-api plugin for the core reactivity, but the code in this article is written for Vue 3 and may need minor adjustments. We recommend upgrading to Vue 3 for the best developer experience.

How do I handle CORS when calling the API from the browser?

The StackPeek API sets appropriate CORS headers for browser-based requests. You can call it directly from fetch() in your Vue component without a proxy. If your organization has strict CSP headers, whitelist us-central1-todd-agent-prod.cloudfunctions.net in your connect-src directive.

What is the rate limit on the free tier?

100 scans per day, per IP address. For a batch scan of 20 competitor URLs, that is 5 full batch runs per day at zero cost. The Pinia caching layer described in this article ensures you don't waste scans on repeat lookups. For heavier usage, paid plans start at $9/month.

More from the Peek Suite