Google Sheets as a REST API in Swift (iOS)
Fetch and display Google Sheet data in an iOS app using SheetsAPI, URLSession, Codable structs, and SwiftUI.
SwiftUI and URLSession make consuming JSON REST APIs straightforward with no external
packages needed. SheetsAPI turns any Google Sheet into a JSON endpoint, so you can drive
an iOS app from a spreadsheet without writing any backend code.
Prerequisites
- Xcode 15+ / Swift 5.9+
- iOS 16+ deployment target
- A Google Sheet with a header row
- A SheetsAPI account and a
YOUR_USER_KEYkey from the dashboard
1. Define Codable models
// Models/SheetResponse.swift
import Foundation
struct SheetMeta: Codable {
let total: Int
let limit: Int
let offset: Int
}
struct SheetResponse<T: Codable>: Codable {
let data: [T]
let meta: SheetMeta
}
struct Product: Codable, Identifiable {
var id: String { name }
let name: String
let price: String
let category: String
let stock: String?
enum CodingKeys: String, CodingKey {
case name, price, category, stock
}
}Identifiable lets SwiftUI use Product directly in List and ForEach.
2. SheetsAPI service
// Services/SheetsService.swift
import Foundation
actor SheetsService {
private let userKey: String
private let apiKey: String?
private let base = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets"
private let decoder: JSONDecoder = {
let d = JSONDecoder()
d.keyDecodingStrategy = .convertFromSnakeCase
return d
}()
init(userKey: String, apiKey: String? = nil) {
self.userKey = userKey
self.apiKey = apiKey
}
func query<T: Codable>(
sheet: String,
limit: Int = 20,
offset: Int = 0,
search: String? = nil,
sort: String? = nil,
fields: String? = nil
) async throws -> SheetResponse<T> {
var components = URLComponents(string: "\(base)/\(userKey)/\(sheet)")!
var queryItems: [URLQueryItem] = [
.init(name: "limit", value: "\(limit)"),
.init(name: "offset", value: "\(offset)"),
]
if let search { queryItems.append(.init(name: "search", value: search)) }
if let sort { queryItems.append(.init(name: "sort", value: sort)) }
if let fields { queryItems.append(.init(name: "fields", value: fields)) }
components.queryItems = queryItems
var request = URLRequest(url: components.url!)
if let key = apiKey {
request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization")
}
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw URLError(.badServerResponse)
}
return try decoder.decode(SheetResponse<T>.self, from: data)
}
func fetchAll<T: Codable>(sheet: String, pageSize: Int = 100) async throws -> [T] {
var all: [T] = []
var offset = 0
while true {
let page: SheetResponse<T> = try await query(sheet: sheet, limit: pageSize, offset: offset)
all.append(contentsOf: page.data)
offset += pageSize
if offset >= page.meta.total { break }
}
return all
}
}Replace YOUR_USER_KEY with your actual key.
3. ViewModel
// ViewModels/ProductsViewModel.swift
import SwiftUI
@MainActor
final class ProductsViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var total = 0
@Published var isLoading = false
@Published var errorMessage: String?
private let service = SheetsService(
userKey: "YOUR_USER_KEY",
apiKey: "sk_your_api_key_here"
)
func load(search: String = "") async {
isLoading = true
errorMessage = nil
do {
let resp: SheetResponse<Product> = try await service.query(
sheet: "Products",
limit: 50,
sort: "name",
search: search.isEmpty ? nil : "name:\(search)"
)
products = resp.data
total = resp.meta.total
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
}4. SwiftUI view
// Views/ProductsView.swift
import SwiftUI
struct ProductsView: View {
@StateObject private var vm = ProductsViewModel()
@State private var searchText = ""
var body: some View {
NavigationStack {
Group {
if vm.isLoading {
ProgressView()
} else if let error = vm.errorMessage {
ContentUnavailableView(
"Error",
systemImage: "exclamationmark.triangle",
description: Text(error)
)
} else {
List(vm.products) { product in
ProductRow(product: product)
}
}
}
.navigationTitle("Products (\(vm.total))")
.searchable(text: $searchText)
.onChange(of: searchText) { _, q in
Task { await vm.load(search: q) }
}
}
.task { await vm.load() }
}
}
struct ProductRow: View {
let product: Product
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(product.name).font(.headline)
HStack {
Text(product.category).font(.caption).foregroundStyle(.secondary)
Spacer()
Text(product.price).font(.subheadline.bold())
}
}
.padding(.vertical, 4)
}
}5. Pagination with infinite scroll
struct InfiniteProductsView: View {
@StateObject private var vm = PaginatedProductsViewModel()
var body: some View {
List {
ForEach(vm.products) { product in
ProductRow(product: product)
.onAppear {
if product.id == vm.products.last?.id {
Task { await vm.loadMore() }
}
}
}
if vm.isLoading {
HStack { Spacer(); ProgressView(); Spacer() }
}
}
.task { await vm.loadMore() }
}
}
@MainActor
final class PaginatedProductsViewModel: ObservableObject {
@Published var products: [Product] = []
@Published var isLoading = false
private var offset = 0
private var done = false
private let service = SheetsService(userKey: "YOUR_USER_KEY")
func loadMore() async {
guard !isLoading && !done else { return }
isLoading = true
let resp: SheetResponse<Product> = try! await service.query(
sheet: "Products", limit: 30, offset: offset, sort: "name"
)
products.append(contentsOf: resp.data)
offset += 30
done = offset >= resp.meta.total
isLoading = false
}
}6. Caching with URLCache
Enable HTTP response caching so repeated requests are served from the system cache:
URLSession.shared.configuration.urlCache = URLCache(
memoryCapacity: 4 * 1024 * 1024, // 4 MB
diskCapacity: 20 * 1024 * 1024 // 20 MB
)SheetsAPI sends standard Cache-Control headers - the URLCache respects them
automatically, so data refreshes on the server-defined schedule without extra code.
Query parameter reference
| Parameter | Example | Description |
|---|---|---|
limit | 50 | Rows per page (default: 20, max: 500) |
offset | 0 | Row offset for pagination |
search | category:books | Filter by field:value |
sort | price or -price | Ascending / descending |
fields | name,price | Return only named columns |
Summary
| Concern | Approach |
|---|---|
| HTTP | URLSession.shared.data(for:) |
| Models | Codable structs + Identifiable |
| Auth | Authorization: Bearer header |
| UI | SwiftUI List + .searchable |
| Async | async/await + @MainActor |
| Cache | URLCache + Cache-Control headers |
No Swift packages needed - URLSession and Codable handle everything with
compile-time type safety and native iOS performance.