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.