All posts
Blog

Using Google Sheets as a Data Source in Vue 3

Fetch, filter, and display Google Sheet data in a Vue 3 app using the Composition API and SheetsAPI — no backend required.

3 min read

Why Google Sheets works as a lightweight database

For internal tools, marketing sites, and small apps, Google Sheets is hard to beat as a data store. Your team already knows how to edit it, it has version history, and you don't have to stand up a database or write admin UIs. The missing piece is a clean API layer that turns a sheet into queryable JSON.

GKit SheetsAPI provides exactly that. Once your sheet is connected, every row becomes a JSON object available at a REST endpoint. In this post we'll wire that endpoint into a Vue 3 app using the Composition API — reactive filtering, live search, and a Pinia store for sharing data across components.

The base URL pattern

Every SheetsAPI request follows this structure:

https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/YOUR_SHEET_NAME

Responses always look like this:

{
  "data": [
    { "name": "Acme Corp", "plan": "pro", "mrr": "1200" },
    { "name": "Beta Inc",  "plan": "free", "mrr": "0" }
  ],
  "meta": { "total": 48, "limit": 20, "offset": 0 }
}

The array you iterate over is response.data, not response.data.rows.

A useSheetsData composable

Start with a composable that encapsulates fetching. This keeps component templates clean and makes the fetching logic reusable across any component in the app.

// composables/useSheetsData.js
import { ref, onMounted } from 'vue'
 
const BASE = 'https://sheetsapi.gkit.mreshank.com/api/spreadsheets'
 
export function useSheetsData(userKey, sheetName, defaultParams = {}) {
  const rows    = ref([])
  const meta    = ref({ total: 0, limit: 20, offset: 0 })
  const loading = ref(false)
  const error   = ref(null)
 
  async function fetchData(params = {}) {
    loading.value = true
    error.value   = null
 
    const query = new URLSearchParams({ ...defaultParams, ...params })
    const url   = `${BASE}/${userKey}/${sheetName}?${query}`
 
    try {
      const res  = await fetch(url, {
        headers: { Authorization: 'Bearer sk_your_key_here' }
      })
      if (!res.ok) throw new Error(`HTTP ${res.status}`)
      const json  = await res.json()
      rows.value  = json.data
      meta.value  = json.meta
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
 
  onMounted(() => fetchData())
 
  return { rows, meta, loading, error, fetchData }
}

Using it in a component is a single line:

<script setup>
import { useSheetsData } from '@/composables/useSheetsData'
 
const { rows, meta, loading, error, fetchData } =
  useSheetsData('YOUR_USER_KEY', 'YOUR_SHEET_NAME', { limit: 25 })
</script>
 
<template>
  <div v-if="loading">Loading…</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="row in rows" :key="row.id">{{ row.name }}</li>
  </ul>
  <p>Showing {{ rows.length }} of {{ meta.total }}</p>
</template>

Reactive filtering with computed and watch

SheetsAPI supports server-side search via ?search=field:value. Vue's watch makes it natural to fire a new request whenever the user's input changes — and computed can derive a display label from the current filter state without extra state variables.

<script setup>
import { ref, computed, watch } from 'vue'
import { useSheetsData } from '@/composables/useSheetsData'
 
const { rows, meta, loading, fetchData } =
  useSheetsData('YOUR_USER_KEY', 'customers')
 
const searchField = ref('name')
const searchValue = ref('')
 
const activeFilter = computed(() =>
  searchValue.value ? `${searchField.value}:${searchValue.value}` : null
)
 
// Re-fetch on the server whenever the search value changes.
// Debounce to avoid a request on every keystroke.
let timer = null
watch(searchValue, (val) => {
  clearTimeout(timer)
  timer = setTimeout(() => {
    fetchData(val ? { search: `${searchField.value}:${val}` } : {})
  }, 300)
})
</script>
 
<template>
  <input v-model="searchValue" placeholder="Search…" />
  <span v-if="activeFilter">Filtering by: {{ activeFilter }}</span>
 
  <table>
    <tr v-for="row in rows" :key="row.id">
      <td>{{ row.name }}</td>
      <td>{{ row.plan }}</td>
    </tr>
  </table>
</template>

Because the filtering happens on the server, this pattern handles sheets with thousands of rows efficiently — only the matched rows are returned, never the whole dataset.

Sharing sheet data with a Pinia store

When multiple components need the same sheet data — a sidebar count, a table, and a chart — lift the fetching into a Pinia store so the request runs once and the data is reactive everywhere.

// stores/customers.js
import { defineStore } from 'pinia'
import { ref } from 'vue'
 
const BASE = 'https://sheetsapi.gkit.mreshank.com/api/spreadsheets'
 
export const useCustomersStore = defineStore('customers', () => {
  const rows    = ref([])
  const meta    = ref({ total: 0, limit: 20, offset: 0 })
  const loading = ref(false)
 
  async function load(params = {}) {
    loading.value = true
    const query   = new URLSearchParams({ limit: 50, ...params })
    const res     = await fetch(
      `${BASE}/YOUR_USER_KEY/customers?${query}`,
      { headers: { Authorization: 'Bearer sk_your_key_here' } }
    )
    const json  = await res.json()
    rows.value  = json.data
    meta.value  = json.meta
    loading.value = false
  }
 
  return { rows, meta, loading, load }
})

Any component can then call useCustomersStore() and get the same reactive rows ref. The store fetches once; watchers in any component pick up changes automatically.

Sorting and pagination

SheetsAPI accepts sort=field for ascending and sort=-field for descending. Combine with limit and offset for cursor-free pagination:

// Page 2 of results sorted by MRR descending
fetchData({ sort: '-mrr', limit: 20, offset: 20 })

A simple pagination component can drive this with two buttons that increment/decrement offset by limit and call fetchData with the new values.

Server-side rendering with Nuxt

If you're using Nuxt 3, swap the fetch call for useFetch — it runs on the server during SSR and hydrates on the client automatically:

<script setup>
const { data, pending } = await useFetch(
  'https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/YOUR_SHEET_NAME',
  {
    headers: { Authorization: 'Bearer sk_your_key_here' },
    query: { limit: 20 }
  }
)
// data.value.data is the array; data.value.meta has total/limit/offset
</script>

useFetch caches the response by URL key, so navigating back to the same page doesn't re-fetch. For dynamic queries (search, sort), use useAsyncData with a computed key that includes the query parameters so the cache invalidates correctly.

Next steps

Share