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.
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.