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.
Next steps
GKit is currently undergoing platform maintenance
Be the first to know when all Google Workspace tools are back online, plus get our engineering deep-dives and product announcements directly to your inbox.