Use Google Sheets as a Backend in Nuxt 3
Read and display Google Sheets data in Nuxt 3 using server routes, useFetch, and Nitro caching.
Nuxt 3's server routes run on the edge or in Node - the same process as your Vue pages. That means your GKit API key never touches the browser, the data arrives pre-rendered for search engines, and Nitro's built-in cache layer means the sheet is queried once every 60 seconds rather than once per visitor. This guide builds a restaurant menu page that demonstrates all three.
Sheet setup
Create a Google Sheet named menu with these columns in row 1:
| item | category | price | description | available |
|---|---|---|---|---|
| Grilled Salmon | Mains | 24.00 | Atlantic salmon, lemon butter, seasonal greens | true |
| Caesar Salad | Starters | 12.00 | Cos lettuce, parmesan, house-made croutons | true |
| Tiramisu | Desserts | 9.00 | Espresso-soaked ladyfingers, mascarpone cream | true |
| Beef Tartare | Starters | 16.00 | Hand-cut beef, capers, quail egg | false |
Add a dozen rows across Starters, Mains, and Desserts. The available column controls whether an item is shown - false means it is 86'd for the night.
Once the sheet is connected in your GKit dashboard you will have a user key, the sheet name (menu), and a secret key starting with sk_.
Runtime config
Store credentials in .env:
# .env
GKIT_API_KEY=sk_your_secret_key_here
GKIT_USER_KEY=your_user_key_hereRegister them in nuxt.config.ts. The top-level runtimeConfig keys are server-only - Nuxt never serialises them into the client bundle:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
gkitApiKey: process.env.GKIT_API_KEY,
gkitUserKey: process.env.GKIT_USER_KEY,
},
});The server route with Nitro caching
Create server/api/menu.get.ts. The route proxies to GKit SheetsAPI and accepts an optional category query parameter for server-side filtering.
// server/api/menu.get.ts
import { defineCachedEventHandler, getQuery, useRuntimeConfig } from "#imports";
interface MenuItem {
item: string;
category: string;
price: string;
description: string;
available: string;
}
interface SheetsResponse {
data: MenuItem[];
meta: { total: number; limit: number; offset: number };
}
export default defineCachedEventHandler(
async (event) => {
const config = useRuntimeConfig(event);
const { category } = getQuery(event);
const params = new URLSearchParams({ limit: "100" });
if (category && typeof category === "string") {
params.set("search", `category:${category}`);
}
const url = `https://api.gkit.io/api/spreadsheets/${config.gkitUserKey}/menu?${params}`;
const res = await $fetch<SheetsResponse>(url, {
headers: { Authorization: `Bearer ${config.gkitApiKey}` },
});
// Only return items that are currently available
return {
...res,
data: res.data.filter((row) => row.available !== "false"),
};
},
{
maxAge: 60, // Nitro caches this response for 60 seconds
name: "menu",
getKey: (event) => {
const { category } = getQuery(event);
return category ? `menu-${category}` : "menu-all";
},
},
);defineCachedEventHandler is a Nitro primitive. On the first request (or after the TTL expires) Nitro calls your handler, stores the result in its cache store (in-memory in dev, a KV store on edge deployments), and serves subsequent requests from cache. The getKey function ensures each category gets its own cache entry - filtering by Mains does not pollute the Starters cache.
The available filter happens after the fetch. SheetsAPI's search param matches a single field, so you filter available in the handler rather than composing a multi-field query.
The MenuItem component
Create components/MenuItem.vue. It receives a single row from the API and renders it as a card:
<!-- components/MenuItem.vue -->
<script setup lang="ts">
defineProps<{
item: string;
category: string;
price: string;
description: string;
}>();
</script>
<template>
<article class="menu-item">
<div class="menu-item-header">
<h3 class="menu-item-name">{{ item }}</h3>
<span class="menu-item-price">${{ Number(price).toFixed(2) }}</span>
</div>
<p class="menu-item-description">{{ description }}</p>
<span class="menu-item-category">{{ category }}</span>
</article>
</template>Nuxt 3 auto-imports components from the components/ directory - no import statement needed in the page.
The menu page
Create pages/menu.vue. It calls the server route, handles loading and error states, and renders a grid of MenuItem cards:
<!-- pages/menu.vue -->
<script setup lang="ts">
interface MenuItem {
item: string;
category: string;
price: string;
description: string;
available: string;
}
const { data, pending, error } = await useFetch<{
data: MenuItem[];
meta: { total: number };
}>("/api/menu");
</script>
<template>
<main>
<h1>Tonight's Menu</h1>
<p v-if="pending">Loading menu…</p>
<p v-else-if="error">Could not load the menu right now. Please try again shortly.</p>
<div v-else class="menu-grid">
<MenuItem
v-for="row in data?.data"
:key="row.item"
:item="row.item"
:category="row.category"
:price="row.price"
:description="row.description"
/>
</div>
</main>
</template>useFetch runs on the server during SSR and passes the pre-fetched payload to the client during hydration - the browser never makes a second request to /api/menu on the initial load. Search engines receive a fully-rendered HTML page.
Reactive category filtering
To let visitors filter by category without a page reload, make the query option a reactive ref. useFetch watches it and re-fetches automatically when it changes:
<!-- pages/menu.vue (updated) -->
<script setup lang="ts">
interface MenuItem {
item: string;
category: string;
price: string;
description: string;
available: string;
}
const categories = ["All", "Starters", "Mains", "Desserts"];
const activeCategory = ref("All");
const query = computed(() =>
activeCategory.value === "All" ? {} : { category: activeCategory.value },
);
const { data, pending, error } = await useFetch<{
data: MenuItem[];
meta: { total: number };
}>("/api/menu", { query });
</script>
<template>
<main>
<h1>Tonight's Menu</h1>
<nav class="category-nav">
<button
v-for="cat in categories"
:key="cat"
:class="{ active: activeCategory === cat }"
@click="activeCategory = cat"
>
{{ cat }}
</button>
</nav>
<p v-if="pending">Loading…</p>
<p v-else-if="error">Could not load the menu right now.</p>
<div v-else class="menu-grid">
<MenuItem v-for="row in data?.data" :key="row.item" v-bind="row" />
</div>
</main>
</template>When activeCategory changes, query recomputes, and useFetch sends a new request to /api/menu?category=Mains. That hits the server route, which checks its Nitro cache - if the Mains cache entry is still warm, the response comes back without touching SheetsAPI at all.
What this gives you
The full stack is now:
- Google Sheets - your content team edits items, toggles
available, adjusts prices - GKit SheetsAPI - turns those rows into a typed REST endpoint with filtering and sorting
- Nitro server route - keeps your API key server-side, filters available items, adds category search
- Nitro cache - absorbs traffic spikes; a busy Friday night service hits SheetsAPI once per minute, not once per request
useFetch+ SSR - first paint includes data; bots and social previews see a complete page
The sheet is the single source of truth. No database migrations, no CMS deployments - update the spreadsheet and the menu reflects the change within 60 seconds.
Ready to connect your first sheet? Create a free GKit account → and have your API key in under two minutes.