Using Google Sheets as a Database with Analog.js
Build an Analog.js application that reads and writes Google Sheets data via the GKit SheetsAPI - with API routes, Angular Signals, and HttpClient.
Analog.js is Angular's answer to the meta-framework moment - file-based routing, Vite-powered builds, and server-side rendering built on top of the Angular you already know. It also ships with Nitro under the hood, which means you get real server-side API routes: TypeScript files that run on Node (or any Nitro-compatible edge runtime) before anything reaches the browser.
That server boundary is exactly what you need when your data lives in Google Sheets. Your GKit SheetsAPI key stays in a server environment variable, the credential never touches the browser bundle, and your Angular components consume a plain JSON endpoint they could have fetched from anywhere. This guide wires those pieces together - from sheet setup through Angular Signals - using a product catalog as the working example.
The sheet
Create a Google Sheet with one header row followed by your product data:
| name | category | price | in_stock | sku |
|---|---|---|---|---|
| Arc Desk Lamp | Lighting | 89.00 | true | LMP-001 |
| Linen Throw Blanket | Textiles | 54.00 | true | TXT-007 |
| Ceramic Pour-Over | Kitchen | 42.00 | false | KIT-019 |
Column headers become field names in every API response. Connect the sheet in your GKit dashboard to receive a userKey. Your GKit endpoint will be:
GET https://api.gkit.io/api/spreadsheets/{userKey}/Products
Supported query parameters relevant to this guide:
| Parameter | Purpose |
|---|---|
search | Filter rows (e.g. search=category:Lighting) |
sort | Column name to sort by; prefix - for descending |
limit / offset | Pagination |
fields | Comma-separated columns to return |
Environment variables
Analog uses Vite, so all environment variables are declared with the VITE_ prefix and accessed via import.meta.env. Add both values to .env at your project root and add .env to .gitignore:
VITE_GKIT_USER_KEY=uk_your_key_here
VITE_GKIT_API_KEY=sk_your_key_here
Because Analog's API routes run on the server via Nitro, import.meta.env.VITE_GKIT_API_KEY is only evaluated server-side inside those route files. It does not appear in any browser bundle as long as you keep the reference inside src/server/routes. Nitro enforces this boundary at build time.
TypeScript interfaces
Define the shape of the GKit response once and share it between the API route and the Angular component. Place this in a shared types file:
// src/app/shared/gkit.types.ts
export interface Product {
name: string;
category: string;
price: number;
in_stock: boolean;
sku: string;
}
export interface GKitMeta {
total: number;
limit: number;
offset: number;
}
export interface GKitResponse<T> {
data: T[];
meta: GKitMeta;
}The generic GKitResponse<T> works for any sheet - swap Product for whatever interface matches your data, and the rest of the plumbing stays identical.
The Analog API route
Analog's server routes live under src/server/routes/api/ and map directly to URL paths. A file named products.get.ts handles GET /api/products. This is where the GKit API key is used - it never leaves this file:
// src/server/routes/api/products.get.ts
import { defineEventHandler, getQuery, createError } from "h3";
import type { GKitResponse, Product } from "~/app/shared/gkit.types";
export default defineEventHandler(async (event) => {
const userKey = import.meta.env.VITE_GKIT_USER_KEY;
const apiKey = import.meta.env.VITE_GKIT_API_KEY;
const { category, sort = "-name", limit = "20", offset = "0" } = getQuery(event);
const params = new URLSearchParams({
sort: String(sort),
limit: String(limit),
offset: String(offset),
});
if (category) {
params.set("search", `category:${category}`);
}
const url = `https://api.gkit.io/api/spreadsheets/${userKey}/Products?${params}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
throw createError({
statusCode: res.status,
statusMessage: `GKit API error: ${res.statusText}`,
});
}
return res.json() as Promise<GKitResponse<Product>>;
});A few details worth noting:
getQuery from h3. Analog's server routes are powered by h3, Nitro's underlying HTTP framework. getQuery(event) parses the incoming request's query string into a plain object. Forwarding category from the Angular component to the GKit search param keeps filtering logic on the server and out of the client bundle.
createError for structured failures. Throwing createError tells Nitro to respond with a proper HTTP error status. The Angular HttpClient will receive a non-2xx response and you can handle it in the component rather than seeing an unhandled promise rejection on the server.
No auth on the internal route. For a public catalog this is fine. If your data is sensitive, add an Authorization check at the top of the handler using a separate secret - the pattern is identical.
The Angular component
The component calls your Analog API route (not GKit directly) and uses Angular Signals to manage reactive state. toSignal converts an Observable into a Signal, which Angular's template engine tracks without needing async pipes or manual subscriptions:
// src/app/pages/products.page.ts
import { Component, inject, signal, effect, computed } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { toSignal } from "@angular/core/rxjs-interop";
import { switchMap } from "rxjs/operators";
import { toObservable } from "@angular/core/rxjs-interop";
import type { GKitResponse, Product } from "../shared/gkit.types";
@Component({
selector: "app-products",
standalone: true,
template: `
<header>
<h1>
Products <span>({{ meta()?.total ?? 0 }})</span>
</h1>
<nav>
@for (cat of categories; track cat) {
<button
[class.active]="activeCategory() === cat"
(click)="activeCategory.set(cat === 'All' ? '' : cat)"
>
{{ cat }}
</button>
}
</nav>
</header>
@if (isLoading()) {
<p class="status">Loading...</p>
} @else if (error()) {
<p class="status error">{{ error() }}</p>
} @else {
<ul class="product-grid">
@for (product of products(); track product.sku) {
<li class="product-card">
<p class="category">{{ product.category }}</p>
<h2>{{ product.name }}</h2>
<p class="price">\${{ product.price.toFixed(2) }}</p>
<span class="badge" [class.badge--out]="!product.in_stock">
{{ product.in_stock ? "In stock" : "Out of stock" }}
</span>
</li>
}
</ul>
}
`,
})
export class ProductsPageComponent {
private http = inject(HttpClient);
readonly categories = ["All", "Lighting", "Textiles", "Kitchen"];
readonly activeCategory = signal<string>("");
readonly isLoading = signal(true);
readonly error = signal<string | null>(null);
private response = toSignal(
toObservable(this.activeCategory).pipe(
switchMap((category) => {
this.isLoading.set(true);
this.error.set(null);
const params: Record<string, string> = { sort: "-name" };
if (category) params["category"] = category;
return this.http.get<GKitResponse<Product>>("/api/products", {
params,
});
}),
),
);
readonly products = computed(() => {
const r = this.response();
this.isLoading.set(false);
return r?.data ?? [];
});
readonly meta = computed(() => this.response()?.meta ?? null);
constructor() {
effect(() => {
// Capture HttpClient errors - toSignal surfaces them here
const r = this.response();
if (r === undefined && !this.isLoading()) {
this.error.set("Could not load products. Please try again.");
}
});
}
}toSignal and toObservable. Signals and Observables interop cleanly in Angular 17+. toObservable(this.activeCategory) turns the writable signal into an RxJS stream so switchMap can cancel the in-flight HTTP request whenever the category changes. toSignal converts the final Observable back into a signal the template reads synchronously.
switchMap for cancellation. When a user clicks a filter before the previous request resolves, switchMap unsubscribes from the earlier HTTP call and starts the new one. The loading state always reflects the most recently requested category.
computed for derived state. products and meta are derived from response using computed. Angular's change detection only re-evaluates them when response changes, which keeps the template efficient.
Error handling
Analog's createError in the API route causes HttpClient to emit on the error channel, not the value channel. To catch it, add an catchError to the observable chain in the component:
import { catchError, of } from "rxjs";
// inside switchMap:
return this.http.get<GKitResponse<Product>>("/api/products", { params }).pipe(
catchError((err) => {
this.error.set(err.error?.statusMessage ?? "Request failed.");
this.isLoading.set(false);
return of(undefined);
}),
);Returning of(undefined) keeps the Observable alive so subsequent filter clicks still work. The error signal drives the template's error state, and isLoading is cleared so the UI does not stall.
What you have now
- An Analog API route at
/api/productsthat holds the GKit API key server-side, forwards query params to the SheetsAPI, and returns typed product data. - An Angular component that uses a writable
signalfor the active category filter, atoSignal-wrappedHttpClientcall that cancels on re-filter, andcomputedsignals for the product list and pagination metadata. - TypeScript interfaces shared between server and client so both ends stay in sync with your sheet's column structure.
The Google Sheet is the data source. Editors add rows, update prices, or mark items out of stock directly in the spreadsheet. No redeploy required - the next request to your Analog route fetches the current data from GKit automatically.
Ready to connect your first sheet? Create a free GKit account at gkit.io/signup - your API key is ready in under a minute and the free tier covers most small projects. The SheetsAPI docs cover every query parameter, authentication option, and pagination pattern.