Use Google Sheets as a Backend in Your React Native App
Fetch and display Google Sheets data in a React Native Expo app - no server required.
Google Sheets is a surprisingly capable lightweight backend for read-heavy mobile apps. Content editors already know how to use it, you skip the database and API server entirely, and GKit exposes your sheet over a clean REST endpoint you can call from anywhere - including a React Native app. This tutorial builds an announcements feed from a Google Sheet using Expo, React Native's FlatList, and a small custom hook.
Why Google Sheets works as a mobile CMS
For apps where the data changes infrequently and the people updating it are non-technical - think news feeds, event listings, menu items, or in-app announcements - Sheets is a genuinely good fit:
- No backend to maintain. GKit turns your sheet into a REST API with filtering, sorting, and pagination built in.
- Editors stay in Sheets. Your content team doesn't need a CMS login; they just edit cells.
- Read-only is safe. Mobile apps only need to fetch data, never write it back, so the attack surface is minimal.
The one thing you must not do is hardcode the API key in your source. We'll cover that first.
Storing the API key safely with Expo
Expo provides a first-class way to inject configuration at build time via app.json (or app.config.js) and the expo-constants package. Values placed in the extra field are bundled into the app but kept out of your source code - and out of your git history.
// app.json
{
"expo": {
"name": "MyApp",
"extra": {
"gkitApiKey": "sk_YOUR_KEY_HERE",
"gkitUserKey": "YOUR_USER_KEY"
}
}
}Warning:
extravalues end up in the compiled JS bundle and are readable by anyone who unpacks it. This is acceptable for read-only public data; for sensitive sheets, route requests through a server-side proxy such as a Cloudflare Worker that holds the key in an environment variable.
Install the package if you haven't already:
npx expo install expo-constantsThen read the values wherever you need them:
import Constants from "expo-constants";
const API_KEY = Constants.expoConfig?.extra?.gkitApiKey as string;
const USER_KEY = Constants.expoConfig?.extra?.gkitUserKey as string;Sheet setup
Create a sheet called announcements with these columns:
| title | body | category | published_at | image_url |
|---|---|---|---|---|
| Welcome to v2! | We just launched … | release | 2026-06-01 | https://… |
| Maintenance window | Scheduled downtime … | ops | 2026-06-15 |
The column names become the keys in every JSON row GKit returns, so keep them lowercase and snake_cased.
The GKit API contract
Every request follows the same shape:
GET https://api.gkit.io/api/spreadsheets/{userKey}/{sheetName}
Authorization: Bearer sk_...
Query parameters:
| Param | Example | Purpose |
|---|---|---|
search | category:release | Filter by field value |
sort | published_at:desc | Sort by field |
limit | 20 | Page size |
offset | 40 | Skip N rows |
fields | title,body,category | Return only these columns |
The response is always:
{
"data": [...],
"meta": { "total": 42, "limit": 20, "offset": 0 }
}A useAnnouncements custom hook
The hook encapsulates fetching, loading state, and error handling so the component stays clean.
// hooks/useAnnouncements.ts
import { useState, useEffect } from "react";
import Constants from "expo-constants";
const API_KEY = Constants.expoConfig?.extra?.gkitApiKey as string;
const USER_KEY = Constants.expoConfig?.extra?.gkitUserKey as string;
const BASE_URL = `https://api.gkit.io/api/spreadsheets/${USER_KEY}/announcements`;
export interface Announcement {
title: string;
body: string;
category: string;
published_at: string;
image_url?: string;
}
interface UseSheetsResult {
data: Announcement[];
total: number;
loading: boolean;
error: string | null;
}
export function useAnnouncements(limit = 20, offset = 0): UseSheetsResult {
const [data, setData] = useState<Announcement[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
async function fetchAnnouncements() {
setLoading(true);
setError(null);
try {
const url = new URL(BASE_URL);
url.searchParams.set("sort", "published_at:desc");
url.searchParams.set("limit", String(limit));
url.searchParams.set("offset", String(offset));
const res = await fetch(url.toString(), {
headers: { Authorization: `Bearer ${API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setData(json.data);
setTotal(json.meta.total);
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Unknown error");
}
} finally {
if (!cancelled) setLoading(false);
}
}
fetchAnnouncements();
return () => {
cancelled = true;
};
}, [limit, offset]);
return { data, total, loading, error };
}The cancelled flag prevents a stale fetch from updating state after the component unmounts - a common React Native gotcha.
A FlatList component for the feed
// screens/AnnouncementsScreen.tsx
import React from "react";
import { FlatList, View, Text, Image, ActivityIndicator, StyleSheet } from "react-native";
import { useAnnouncements, Announcement } from "../hooks/useAnnouncements";
function AnnouncementCard({ item }: { item: Announcement }) {
return (
<View style={styles.card}>
{item.image_url ? <Image source={{ uri: item.image_url }} style={styles.image} /> : null}
<View style={styles.cardBody}>
<Text style={styles.category}>{item.category.toUpperCase()}</Text>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.body} numberOfLines={3}>
{item.body}
</Text>
<Text style={styles.date}>{item.published_at}</Text>
</View>
</View>
);
}
export default function AnnouncementsScreen() {
const { data, loading, error } = useAnnouncements(20, 0);
if (loading) return <ActivityIndicator style={styles.center} />;
if (error) return <Text style={styles.center}>Failed to load: {error}</Text>;
return (
<FlatList
data={data}
keyExtractor={(item, index) => `${item.published_at}-${index}`}
renderItem={({ item }) => <AnnouncementCard item={item} />}
contentContainerStyle={styles.list}
/>
);
}
const styles = StyleSheet.create({
center: { flex: 1, alignSelf: "center", marginTop: 40 },
list: { padding: 16, gap: 12 },
card: {
backgroundColor: "#fff",
borderRadius: 12,
overflow: "hidden",
shadowColor: "#000",
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
},
image: { width: "100%", height: 160 },
cardBody: { padding: 12 },
category: {
fontSize: 11,
fontWeight: "700",
color: "#6366f1",
marginBottom: 4,
},
title: { fontSize: 16, fontWeight: "700", marginBottom: 6 },
body: { fontSize: 14, color: "#555", lineHeight: 20 },
date: { fontSize: 12, color: "#999", marginTop: 8 },
});FlatList virtualises the list automatically, so performance stays smooth even if the sheet grows to hundreds of rows.
Optional: AsyncStorage caching for offline use
Install the package:
npx expo install @react-native-async-storage/async-storageAdd a cache layer around the fetch inside useAnnouncements:
import AsyncStorage from "@react-native-async-storage/async-storage";
const CACHE_KEY = "announcements_cache";
const TTL_MS = 5 * 60 * 1000; // 5 minutes
// Before the fetch:
const cached = await AsyncStorage.getItem(CACHE_KEY);
if (cached) {
const { timestamp, payload } = JSON.parse(cached);
if (Date.now() - timestamp < TTL_MS) {
setData(payload.data);
setTotal(payload.meta.total);
setLoading(false);
return;
}
}
// After a successful fetch, before setData:
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify({ timestamp: Date.now(), payload: json }));Now the app renders instantly on relaunch and gracefully degrades when the device is offline - stale data is always better than a blank screen.
What's next
The same pattern works for any sheet: product catalogues, event schedules, FAQ content. You can add pagination by exposing the offset as state and wiring it to a "Load more" button using FlatList's onEndReached prop. You can also filter by category with a tab bar that changes the search param passed into the hook.
Ready to connect your first sheet? Sign up for GKit and get your API key in under two minutes.