Use Google Sheets as a Live Database with Vue 3
Fetch, search, and paginate Google Sheets data in Vue 3 - no backend required.
Why Vue 3 and Sheets are a natural fit
Vue 3's Composition API was built around the idea that data fetching, state, and derived values belong together in one place - a composable. That mental model maps almost perfectly onto how you consume a REST API like GKit SheetsAPI: fetch rows, track loading state, expose filtered and paginated slices, and react when the user changes a filter or page.
The result is that you can go from a raw spreadsheet to a fully searchable, paginated data table in a single file, with zero backend code. Your sheet is your database. SheetsAPI is your query layer.
Sheet setup
For this tutorial we will use a team directory sheet. Create a Google Sheet named team with these columns in row 1:
| name | role | department | avatar_url | |
|---|---|---|---|---|
| Alice Mbeki | Engineering Lead | Engineering | alice@example.com | https://... |
| Ben Ortiz | Product Manager | Product | ben@example.com | https://... |
| Carol Zhang | Designer | Design | carol@example.com | https://... |
Add a dozen rows. Realistic variety helps - mix departments so the filter you will build later has something interesting to show.
Once the sheet is connected in your GKit dashboard, you will have:
- A user key - your account identifier
- A sheet name -
team(matches the tab name exactly, case-sensitive) - A secret key -
sk_...
The base URL for every request is:
GET https://api.gkit.io/api/spreadsheets/{userKey}/team
Authorization: Bearer sk_...
Store the key in a .env file and never commit it:
# .env
VITE_SHEETS_API_KEY=sk_your_secret_key_here
VITE_SHEETS_USER_KEY=your_user_key_hereVite exposes any variable prefixed with VITE_ to browser code via import.meta.env.
The useSheetData composable
Create src/composables/useSheetData.js. This composable owns all the data-fetching logic so your components stay lean.
// src/composables/useSheetData.js
import { ref, computed, watch, onMounted } from "vue";
const BASE_URL = `https://api.gkit.io/api/spreadsheets/${import.meta.env.VITE_SHEETS_USER_KEY}`;
const API_KEY = import.meta.env.VITE_SHEETS_API_KEY;
export function useSheetData(sheetName, options = {}) {
const { pageSize = 10, searchField = null } = options;
const rows = ref([]);
const total = ref(0);
const currentPage = ref(1);
const searchValue = ref("");
const isLoading = ref(false);
const error = ref(null);
const totalPages = computed(() => (total.value ? Math.ceil(total.value / pageSize) : 1));
const offset = computed(() => (currentPage.value - 1) * pageSize);
async function fetchRows() {
isLoading.value = true;
error.value = null;
const params = new URLSearchParams({
limit: pageSize,
offset: offset.value,
});
if (searchField && searchValue.value) {
params.set("search", `${searchField}:${searchValue.value}`);
}
try {
const res = await fetch(`${BASE_URL}/${sheetName}?${params}`, {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
const json = await res.json();
rows.value = json.data;
total.value = json.meta.total;
} catch (err) {
error.value = err.message;
} finally {
isLoading.value = false;
}
}
// Re-fetch whenever page or search term changes.
// Reset to page 1 when the search term changes so the offset is valid.
watch(searchValue, () => {
currentPage.value = 1;
});
watch([currentPage, searchValue], fetchRows);
onMounted(fetchRows);
return {
rows,
total,
totalPages,
currentPage,
searchValue,
isLoading,
error,
};
}A few things worth noting:
watch([currentPage, searchValue], fetchRows)is a single watcher on an array of sources. Vue 3 callsfetchRowswhenever either changes.- Resetting
currentPageto1inside thesearchValuewatcher prevents stale offsets when a user types a new filter mid-page. onMountedtriggers the first fetch so the component is populated immediately.
Component: department filter and pagination
Now wire the composable into a component. Create src/components/TeamDirectory.vue:
<template>
<div class="team-directory">
<!-- Department filter -->
<div class="filters">
<label for="dept">Filter by department</label>
<select id="dept" v-model="searchValue">
<option value="">All departments</option>
<option value="Engineering">Engineering</option>
<option value="Product">Product</option>
<option value="Design">Design</option>
</select>
</div>
<!-- Loading / error states -->
<p v-if="isLoading" class="status">Loading…</p>
<p v-else-if="error" class="status error">{{ error }}</p>
<!-- Team grid -->
<ul v-else class="team-grid">
<li v-for="person in rows" :key="person.email" class="card">
<img :src="person.avatar_url" :alt="person.name" class="avatar" />
<div class="info">
<strong>{{ person.name }}</strong>
<span>{{ person.role }}</span>
<span class="dept">{{ person.department }}</span>
</div>
</li>
</ul>
<!-- Pagination -->
<div class="pagination">
<button :disabled="currentPage === 1" @click="currentPage--">Previous</button>
<span>Page {{ currentPage }} of {{ totalPages }}</span>
<button :disabled="currentPage === totalPages" @click="currentPage++">Next</button>
</div>
<p class="total">{{ total }} people total</p>
</div>
</template>
<script setup>
import { useSheetData } from "../composables/useSheetData";
const { rows, total, totalPages, currentPage, searchValue, isLoading, error } = useSheetData(
"team",
{
pageSize: 8,
searchField: "department",
},
);
</script>The <select> is bound to searchValue via v-model. When a user picks "Engineering", the watcher in the composable fires, resets the page to 1, and triggers a fresh fetch with search=department:Engineering. Pagination buttons simply increment or decrement currentPage, which the other watcher turns into a new offset automatically.
The component itself has no fetch calls, no ref declarations, no lifecycle hooks - all of that lives in useSheetData, and the component only concerns itself with rendering.
Moving to Nuxt 3 later
If you later migrate to Nuxt 3 and want server-side rendering, the composable swap is minimal. Nuxt's useFetch is SSR-aware: it runs on the server during the initial page load and hydrates on the client, so you get fast first paints without an extra network round-trip in the browser.
// Inside a Nuxt page or composable
const { data, pending, error } = await useFetch(
`https://api.gkit.io/api/spreadsheets/${userKey}/team`,
{
headers: { Authorization: `Bearer ${apiKey}` },
query: { limit: 8, offset: 0 },
},
);In a Nuxt setup you would move the API key to runtimeConfig.sheetsApiKey (a private key, never exposed to the browser) and call the API from a server route instead of directly from the client. The structure of the response - { data, meta } - stays the same, so the template needs no changes at all.
Start building
GKit SheetsAPI gives Vue 3 the live, queryable backend it needs without a database, a server, or a deployment pipeline for your data layer. Set up your sheet, grab your key from the GKit dashboard, and your composable is the only file between a spreadsheet and a polished UI.