Google Sheets as a database with Angular HttpClient
Use SheetsAPI to fetch and display Google Sheets data in an Angular app - HttpClient services, async pipe, reactive search with RxJS, and environment variables.
Google Sheets is a surprisingly capable lightweight database - it handles small to medium datasets, anyone on your team can edit it, and you don't have to manage migrations. SheetsAPI exposes your sheets over a REST endpoint, so you can query them like any JSON API. This tutorial wires that API into an Angular app using HttpClient, RxJS, and Angular 17 signals.
The SheetsAPI contract
Every request follows the same shape:
GET https://api.sheetsapi.io/api/spreadsheets/{userKey}/{sheetName}
Authorization: Bearer sk_...
Query parameters:
| Param | Example | Purpose |
|---|---|---|
search | status:active | Filter by field value |
sort | name:asc | Sort by field |
limit | 20 | Page size |
offset | 40 | Skip N rows |
fields | id,name,email | Return only these columns |
The response is always:
{
"data": [...],
"meta": { "total": 142, "limit": 20, "offset": 0 }
}Set up the Angular project
ng new sheets-demo --routing --style=css
cd sheets-demoStore the API key in environment files so it is not hard-coded in source.
// src/environments/environment.ts
export const environment = {
production: false,
sheetsApiKey: "sk_YOUR_KEY_HERE",
sheetsUserKey: "YOUR_USER_KEY",
};// src/environments/environment.prod.ts
export const environment = {
production: true,
sheetsApiKey: process.env["SHEETS_API_KEY"] ?? "",
sheetsUserKey: process.env["SHEETS_USER_KEY"] ?? "",
};Warning: The browser-side environment file embeds the key in the JavaScript bundle. For public apps, use the proxy or SSR approach covered at the end of this post.
Typed HttpClient service
Define interfaces that mirror the API response and then inject HttpClient.
// src/app/sheets/sheets.types.ts
export interface SheetsMeta {
total: number;
limit: number;
offset: number;
}
export interface SheetsResponse<T> {
data: T[];
meta: SheetsMeta;
}
export interface SheetsParams {
search?: string;
sort?: string;
limit?: number;
offset?: number;
fields?: string;
}// src/app/sheets/sheets.service.ts
import { Injectable, inject } from "@angular/core";
import { HttpClient, HttpParams } from "@angular/common/http";
import { Observable } from "rxjs";
import { environment } from "../../environments/environment";
import { SheetsResponse, SheetsParams } from "./sheets.types";
@Injectable({ providedIn: "root" })
export class SheetsService {
private http = inject(HttpClient);
private base = "https://api.sheetsapi.io/api/spreadsheets";
query<T>(sheetName: string, params: SheetsParams = {}): Observable<SheetsResponse<T>> {
let httpParams = new HttpParams();
if (params.search) httpParams = httpParams.set("search", params.search);
if (params.sort) httpParams = httpParams.set("sort", params.sort);
if (params.limit) httpParams = httpParams.set("limit", params.limit);
if (params.offset) httpParams = httpParams.set("offset", params.offset);
if (params.fields) httpParams = httpParams.set("fields", params.fields);
return this.http.get<SheetsResponse<T>>(
`${this.base}/${environment.sheetsUserKey}/${sheetName}`,
{
headers: { Authorization: `Bearer ${environment.sheetsApiKey}` },
params: httpParams,
},
);
}
}Register HttpClient in app.config.ts (standalone bootstrap):
import { provideHttpClient } from "@angular/common/http";
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient()],
};Display data with the async pipe
The async pipe subscribes to an Observable and handles unsubscription automatically - no manual ngOnDestroy needed.
// src/app/products/products.component.ts
import { Component, inject } from "@angular/core";
import { AsyncPipe, NgFor } from "@angular/common";
import { SheetsService } from "../sheets/sheets.service";
interface Product {
id: string;
name: string;
price: string;
category: string;
}
@Component({
selector: "app-products",
standalone: true,
imports: [AsyncPipe, NgFor],
template: `
@if (products$ | async; as result) {
<p>{{ result.meta.total }} products</p>
<ul>
@for (p of result.data; track p.id) {
<li>{{ p.name }} - {{ p.price }}</li>
}
</ul>
}
`,
})
export class ProductsComponent {
private sheets = inject(SheetsService);
products$ = this.sheets.query<Product>("products", {
limit: 20,
sort: "name:asc",
});
}Reactive search with RxJS
Wrap a search input in an Observable so that each keystroke fires a new API call after a short pause.
// src/app/products/products.component.ts (extended)
import { Component, inject, signal } from '@angular/core';
import { AsyncPipe } from '@angular/common';
import { FormControl, ReactiveFormsModule } from '@angular/forms';
import { switchMap, debounceTime, distinctUntilChanged, startWith } from 'rxjs/operators';
import { SheetsService } from '../sheets/sheets.service';
interface Product { id: string; name: string; price: string; category: string; }
@Component({
selector: 'app-products',
standalone: true,
imports: [AsyncPipe, ReactiveFormsModule],
template: `
<input [formControl]="searchCtrl" placeholder="Search by name…" />
@if (products$ | async; as result) {
<p>{{ result.meta.total }} results</p>
@for (p of result.data; track p.id) {
<div>{{ p.name }} - ${{ p.price }}</div>
}
}
`,
})
export class ProductsComponent {
private sheets = inject(SheetsService);
searchCtrl = new FormControl('');
products$ = this.searchCtrl.valueChanges.pipe(
startWith(''),
debounceTime(300),
distinctUntilChanged(),
switchMap(term =>
this.sheets.query<Product>('products', {
search: term ? `name:${term}` : undefined,
limit: 20,
sort: 'name:asc',
})
)
);
}debounceTime(300) waits 300 ms after the user stops typing. switchMap cancels any in-flight request when a newer search arrives, preventing race conditions.
Signal-based pagination (Angular 17+)
Angular 17 signals pair well with pagination state.
readonly page = signal(0);
readonly pageSize = 20;
products$ = toObservable(this.page).pipe(
switchMap(p =>
this.sheets.query<Product>('products', {
limit: this.pageSize,
offset: p * this.pageSize,
})
)
);
next() { this.page.update(p => p + 1); }
prev() { this.page.update(p => Math.max(0, p - 1)); }toObservable (from @angular/core/rxjs-interop) converts a signal into an Observable so you can pipe it into your existing RxJS chain.
Keeping the API key server-side
Embedding a secret in a client bundle is fine for internal tools, but for public-facing apps you should proxy the request through a backend.
| Approach | API key location | Complexity | Best for |
|---|---|---|---|
| Direct HttpClient | JS bundle (visible) | Low | Internal / dev tools |
Angular proxy (proxy.conf.json) | Dev server only | Low | Local development |
| Angular Universal / SSR route | Node.js server | Medium | Public production apps |
| Dedicated backend (Express, Workers) | Separate service | High | Multi-tenant or high-traffic |
Angular dev proxy - add a proxy.conf.json to strip the auth header from the browser and re-add it server-side:
{
"/sheets-proxy": {
"target": "https://api.sheetsapi.io",
"pathRewrite": { "^/sheets-proxy": "/api/spreadsheets" },
"headers": { "Authorization": "Bearer sk_YOUR_KEY_HERE" },
"changeOrigin": true,
"secure": true
}
}Point Angular to it in angular.json:
"serve": {
"options": { "proxyConfig": "proxy.conf.json" }
}Then call /sheets-proxy/YOUR_USER_KEY/products instead of the full SheetsAPI URL - the key never leaves the dev server.
For production, wrap the same proxy logic in an Angular Universal API route or a lightweight Cloudflare Worker that reads the key from a secret binding.
What's next
- Add error handling with
catchErrorand display a friendly message when the sheet is unreachable. - Cache results with
shareReplay(1)to avoid duplicate network calls when the same component mounts multiple times. - Combine search, sort, and pagination signals into a single
queryParamscomputed signal and pipe the whole thing throughswitchMap.
SheetsAPI gives you a queryable REST layer on top of any Google Sheet in minutes - no backend required for trusted clients, and a thin proxy for anything public-facing.