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.
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.