All posts
Blog

Fetch Google Sheets Data Directly in the Browser

SheetsAPI returns proper CORS headers so you can call it from any frontend — React, Vue, vanilla JS — without a proxy or backend.

4 min read

The CORS problem with the Google Sheets API

If you've tried calling the Google Sheets API directly from browser JavaScript, you've hit this:

Access to fetch at 'https://sheets.googleapis.com/...' from origin 'http://localhost:3000'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on
the requested resource.

The Google Sheets API requires OAuth2 access tokens, which means authentication must happen server-side. Even if you get a token, the API doesn't set Access-Control-Allow-Origin headers for arbitrary browser origins — it expects requests from Google's own frontends or server-side code. There's no supported way to call it directly from a browser without running your own backend.

The common workarounds — a serverless function, an Express proxy, a Google Apps Script web app — all add infrastructure and latency. For a static site or a simple dashboard, that's overhead you shouldn't need.

SheetsAPI ships CORS headers

SheetsAPI sets Access-Control-Allow-Origin: * on every response. Any browser origin can call it directly. No proxy needed.

The tradeoff: API keys embedded in frontend code are visible to anyone who opens DevTools. For public sheets (no API key required), this is a non-issue. For private sheets, see the section at the end on keeping keys server-side.

Vanilla JavaScript

<div id="output"></div>
 
<script>
  const USER_KEY = "YOUR_USER_KEY";
  const SHEET_NAME = "YOUR_SHEET_NAME";
  const url = `https://sheetsapi.gkit.mreshank.com/api/spreadsheets/${USER_KEY}/${SHEET_NAME}`;
 
  fetch(url)
    .then(res => {
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return res.json();
    })
    .then(payload => {
      const rows = payload.data;
      const list = document.getElementById("output");
      rows.forEach(row => {
        const li = document.createElement("li");
        li.textContent = JSON.stringify(row);
        list.appendChild(li);
      });
    })
    .catch(err => console.error("Fetch failed:", err));
</script>

With query parameters for filtering and sorting:

const params = new URLSearchParams({
  search: "status:published",
  sort: "-date",
  limit: "10",
});
 
fetch(`${url}?${params}`)
  .then(res => res.json())
  .then(payload => console.log(payload.data));

React — useEffect

The pattern for React class components or older codebases:

import { useState, useEffect } from "react";
 
const USER_KEY = "YOUR_USER_KEY";
const SHEET_NAME = "YOUR_SHEET_NAME";
const API_URL = `https://sheetsapi.gkit.mreshank.com/api/spreadsheets/${USER_KEY}/${SHEET_NAME}`;
 
function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetch(`${API_URL}?sort=-date&limit=20`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`API error: ${res.status}`);
        return res.json();
      })
      .then(payload => {
        setPosts(payload.data);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== "AbortError") {
          setError(err.message);
          setLoading(false);
        }
      });
 
    return () => controller.abort();
  }, []);
 
  if (loading) return <p>Loading…</p>;
  if (error)   return <p>Error: {error}</p>;
 
  return (
    <ul>
      {posts.map((post, i) => (
        <li key={i}>{post.title} — {post.date}</li>
      ))}
    </ul>
  );
}

The AbortController cleanup prevents a state update after the component unmounts — a common source of React warnings with async effects.

React — with a custom hook

If you're fetching from sheets in multiple components, extract the logic:

import { useState, useEffect } from "react";
 
function useSheetsData(userKey, sheetName, params = {}) {
  const [data, setData]     = useState([]);
  const [meta, setMeta]     = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError]   = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
    const query = new URLSearchParams(params).toString();
    const url = `https://sheetsapi.gkit.mreshank.com/api/spreadsheets/${userKey}/${sheetName}`;
 
    fetch(query ? `${url}?${query}` : url, { signal: controller.signal })
      .then(res => res.json())
      .then(payload => {
        setData(payload.data);
        setMeta(payload.meta);
        setLoading(false);
      })
      .catch(err => {
        if (err.name !== "AbortError") {
          setError(err.message);
          setLoading(false);
        }
      });
 
    return () => controller.abort();
  }, [userKey, sheetName, JSON.stringify(params)]);
 
  return { data, meta, loading, error };
}
 
// Usage
function EventList() {
  const { data: events, loading } = useSheetsData(
    "YOUR_USER_KEY",
    "events",
    { sort: "date", limit: "50" }
  );
 
  if (loading) return <p>Loading…</p>;
  return <ul>{events.map((e, i) => <li key={i}>{e.title}</li>)}</ul>;
}

Vue 3 — Composition API

<script setup>
import { ref, onMounted } from "vue";
 
const USER_KEY  = "YOUR_USER_KEY";
const SHEET_NAME = "YOUR_SHEET_NAME";
 
const rows    = ref([]);
const loading = ref(true);
const error   = ref(null);
 
onMounted(async () => {
  try {
    const res = await fetch(
      `https://sheetsapi.gkit.mreshank.com/api/spreadsheets/${USER_KEY}/${SHEET_NAME}?sort=-date`
    );
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    const payload = await res.json();
    rows.value = payload.data;
  } catch (err) {
    error.value = err.message;
  } finally {
    loading.value = false;
  }
});
</script>
 
<template>
  <div v-if="loading">Loading…</div>
  <div v-else-if="error">{{ error }}</div>
  <ul v-else>
    <li v-for="(row, i) in rows" :key="i">{{ row.title }}</li>
  </ul>
</template>

Using SWR for automatic revalidation

If you're in a React project already using SWR, the integration is straightforward:

import useSWR from "swr";
 
const fetcher = url => fetch(url).then(res => res.json()).then(p => p.data);
 
function LiveData() {
  const url =
    "https://sheetsapi.gkit.mreshank.com/api/spreadsheets/YOUR_USER_KEY/YOUR_SHEET_NAME" +
    "?sort=-timestamp&limit=20";
 
  const { data, error, isLoading } = useSWR(url, fetcher, {
    refreshInterval: 30000, // re-fetch every 30 seconds
  });
 
  if (isLoading) return <p>Loading…</p>;
  if (error)     return <p>Error loading data.</p>;
 
  return <ul>{data.map((row, i) => <li key={i}>{row.message}</li>)}</ul>;
}

SWR handles deduplication, background revalidation, and focus-based refresh without any extra setup.

API keys in the browser: what's safe and what isn't

Public sheet, no API key — fully safe to call from the browser. Anyone can see your user key in DevTools, but there's nothing sensitive about it — it just identifies which sheet to read.

Private sheet, API key required — the API key grants read access to your private data. Embedding it in frontend JavaScript means anyone can extract it and read your sheet. Whether that's acceptable depends on how sensitive the data is.

For truly private data, move the fetch to the server:

  • In Next.js, fetch in a Server Component or Route Handler and pass data down as props. The API key stays in process.env and never reaches the browser.
  • In SvelteKit, use a +page.server.ts load function.
  • In Nuxt, use useFetch in a server route.

For data that's not sensitive but should be rate-limited, a read-only API key with restricted permissions is a reasonable middle ground — the worst case is someone scraping your sheet, not writing to it.


Share