Using Google Sheets as a database with Nuxt
Fetch Google Sheet data in Nuxt server routes and pages - build a full-stack Nuxt app backed by a spreadsheet using SheetsAPI.
Why a spreadsheet makes sense as a backend
Not every project needs a Postgres instance. A product catalog maintained by a non-technical teammate, a conference speaker list, a small CMS for blog metadata - these live naturally in Google Sheets. The problem is getting that data into your Nuxt app without exposing your Google service account credentials to the browser.
SheetsAPI solves this: it sits in front of your spreadsheet and gives you a clean REST endpoint. You call it from your Nuxt server routes, keep your credentials server-side, and expose only what your frontend needs.
The SheetsAPI contract
Every request follows the same shape:
GET https://api.sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
Authorization: Bearer sk_...
The {userKey} is your account identifier. You can authenticate either via the Authorization header (preferred) or by embedding the key in the path - useful for quick tests, but use the header in production.
Query parameters:
| Parameter | Example | Description |
|---|---|---|
search | search=status:active | Filter rows where field equals value |
sort | sort=name:asc | Sort by a column, ascending or descending |
limit | limit=20 | Max rows to return |
offset | offset=40 | Skip N rows (use with limit for pagination) |
fields | fields=name,email,role | Return only these columns |
The response is always:
{
"data": [ { "name": "Alice", "role": "speaker" }, ... ],
"meta": { "total": 142, "limit": 20, "offset": 0 }
}meta.total is the count of all matching rows before pagination - exactly what you need for a page count.
Project setup
npx nuxi@latest init sheets-demo
cd sheets-demoStore your API key in .env (never commit this file):
# .env
SHEETSAPI_KEY=sk_your_secret_key_hereExpose it as a private runtime config in nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
sheetsApiKey: process.env.SHEETSAPI_KEY, // server-only
public: {
sheetsUserKey: "YOUR_USER_KEY", // safe to expose
},
},
});runtimeConfig keys at the top level are only accessible in server-side code. Keys under public are bundled into the client. The API key never reaches the browser.
Writing a Nuxt server route
Server routes live in server/api/ and run only on the server - in Node.js during SSR, or as serverless functions when deployed to Vercel, Netlify, or Cloudflare Pages.
// server/api/speakers.get.ts
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
const query = getQuery(event);
const params = new URLSearchParams();
if (query.page) {
const page = Number(query.page) || 1;
params.set("limit", "20");
params.set("offset", String((page - 1) * 20));
}
if (query.search) {
params.set("search", `name:${query.search}`);
}
const url =
`https://api.sheetsapi.io/api/spreadsheets/${config.public.sheetsUserKey}/speakers` +
(params.size ? `?${params}` : "");
const res = await $fetch<{ data: unknown[]; meta: Record<string, number> }>(url, {
headers: { Authorization: `Bearer ${config.sheetsApiKey}` },
});
return res;
});This route:
- Reads the API key from the private runtime config (server-only)
- Accepts
pageandsearchquery params from the caller - Translates them into SheetsAPI's
limit/offset/searchparams - Returns the raw
{ data, meta }shape to the page
Fetching data in a page with useFetch
useFetch is the idiomatic way to call your own server routes. It runs on the server during SSR (so the page renders with data) and hydrates on the client without a second network request.
<!-- pages/speakers.vue -->
<script setup lang="ts">
const route = useRoute();
const page = computed(() => Number(route.query.page) || 1);
const search = computed(() => String(route.query.search || ""));
const { data, pending, refresh } = await useFetch("/api/speakers", {
query: { page, search },
watch: [page, search],
});
const totalPages = computed(() => (data.value ? Math.ceil(data.value.meta.total / 20) : 1));
</script>
<template>
<div>
<input
:value="search"
placeholder="Search speakers..."
@input="
navigateTo({
query: { search: ($event.target as HTMLInputElement).value, page: 1 },
})
"
/>
<ul v-if="!pending">
<li v-for="speaker in data?.data" :key="(speaker as any).email">
{{ (speaker as any).name }} - {{ (speaker as any).role }}
</li>
</ul>
<div>
<button :disabled="page <= 1" @click="navigateTo({ query: { page: page - 1 } })">
Previous
</button>
<span>Page {{ page }} of {{ totalPages }}</span>
<button :disabled="page >= totalPages" @click="navigateTo({ query: { page: page + 1 } })">
Next
</button>
</div>
</div>
</template>Passing watch: [page, search] tells useFetch to re-run whenever those reactive values change - no manual refresh() calls needed for query param changes.
When to use useAsyncData instead
useAsyncData gives you more control when you need to transform the response or compose multiple fetches:
const { data: speakers } = await useAsyncData(
`speakers-page-${page.value}`, // cache key - must be unique per dataset
async () => {
const res = await $fetch<{ data: Speaker[]; meta: { total: number } }>("/api/speakers", {
query: { page: page.value },
});
return {
items: res.data,
total: res.meta.total,
pages: Math.ceil(res.meta.total / 20),
};
},
);The cache key is important: Nuxt uses it to deduplicate requests and rehydrate state from the server payload. If two components use the same key, they share the same fetch result.
SSR vs. SSG
| Mode | How it works | Best for |
|---|---|---|
| SSR (default) | Server route runs on every request; data is always fresh | Frequently updated sheets |
SSG (nuxt generate) | Server routes run at build time; output is static HTML | Rarely changing content |
Client-only (lazy: true) | useFetch skips SSR; data loads after hydration | Non-critical, user-specific data |
For SSG, the server route executes during nuxt generate and the result is baked into the HTML. SheetsAPI is called at build time, not at runtime - ideal for a speaker list that updates once a week.
To switch to SSG, add ssr: false per-route or run nuxt generate. Your server routes still work; they just run at build time.
Selecting specific columns
If your sheet has 20 columns but your page only needs three, use the fields param to reduce the payload:
// server/api/speakers.get.ts (updated)
params.set("fields", "name,role,photo_url");This keeps the response small and avoids accidentally leaking internal columns (budget figures, private notes) to the frontend.
What to use when
| Scenario | Approach |
|---|---|
| Server-rendered page with fresh data | useFetch in <script setup> → calls your server route |
| Composed or transformed data | useAsyncData with a stable cache key |
| Static site (infrequent updates) | nuxt generate - server routes run at build time |
| Client-side-only widget | useFetch with { lazy: true } |
| Direct server-to-SheetsAPI (no page) | $fetch inside defineEventHandler |
Next steps
The pattern here - server route as a thin adapter, useFetch for reactive data, meta.total for pagination - scales to any sheet in your account. Swap speakers for any tab name, adjust the fields param to match your columns, and you have a working read-only backend in under an hour.