Use Google Sheets as a Data Source in Ruby on Rails
Read and query Google Sheets rows in a Rails app using the GKit SheetsAPI - Net::HTTP, plain Ruby structs, and Rails.cache.
Rails has good HTTP options - pick the right one
Rails apps that pull from external data sources typically reach for one of three tools: the bundled Net::HTTP, the popular Faraday gem, or HTTParty. For a single, well-defined API with a consistent response shape, you don't need Faraday's middleware stack or HTTParty's magic - a small wrapper around Net::HTTP is explicit, dependency-free, and trivial to test.
This post shows how to wire GKit SheetsAPI into a Rails app to use a Google Sheet as a live data source. The pattern fits content-driven apps well: product catalogues, FAQs, event listings, pricing tables - anything a non-developer needs to edit without a CMS.
Set up the sheet
Create a Google Sheet with these columns in row 1:
| name | category | price | sku | in_stock |
|---|---|---|---|---|
| Wireless Mouse | peripherals | 29.99 | WM-001 | TRUE |
| Mechanical Keyboard | peripherals | 89.99 | MK-002 | TRUE |
| USB-C Hub | accessories | 44.99 | UH-003 | FALSE |
Name the sheet tab products. The SheetsAPI endpoint for this sheet will be:
GET https://api.gkit.io/api/spreadsheets/{userKey}/products
Your userKey is the identifier tied to your GKit account. Keep the column names lowercase with underscores - they become the field names in every API response.
The SheetsApiClient class
Put this in app/services/sheets_api_client.rb. It wraps Net::HTTP, injects the bearer token, and raises a descriptive error when the API returns a non-200.
# app/services/sheets_api_client.rb
require "net/http"
require "json"
class SheetsApiClient
BASE_URL = "https://api.gkit.io/api/spreadsheets"
def initialize(user_key:, api_key:)
@user_key = user_key
@api_key = api_key
end
# Returns { data: [...], meta: { total:, limit:, offset: } }
def query(sheet_name, params = {})
uri = URI("#{BASE_URL}/#{@user_key}/#{sheet_name}")
uri.query = URI.encode_www_form(params.compact) if params.any?
request = Net::HTTP::Get.new(uri)
request["Authorization"] = "Bearer #{@api_key}"
request["Accept"] = "application/json"
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(request)
end
unless response.is_a?(Net::HTTPSuccess)
raise "SheetsAPI error #{response.code}: #{response.body}"
end
JSON.parse(response.body, symbolize_names: true)
end
endA few deliberate choices here:
use_ssl: true- the API is HTTPS only.params.compact- callers can passnilvalues and they are silently dropped rather than sendingsearch=with an empty value.symbolize_names: true- keeps downstream code consistent; every key is a symbol.
The Product struct
Parsing raw hashes in the controller is messy. A Struct gives you named accessors and a single place to coerce types - the sheet stores in_stock as the string "TRUE" or "FALSE", so it is worth normalising here.
# app/models/product.rb
Product = Struct.new(:name, :category, :price, :sku, :in_stock, keyword_init: true) do
def self.from_row(row)
new(
name: row[:name],
category: row[:category],
price: row[:price].to_f,
sku: row[:sku],
in_stock: row[:in_stock].to_s.upcase == "TRUE"
)
end
def available?
in_stock
end
endkeyword_init: true means Product.new(name: "Wireless Mouse", ...) rather than positional arguments - safer to add fields later.
The controller
Two actions: an index that supports filtering by category, and a show that finds a product by SKU using the search parameter.
# app/controllers/products_controller.rb
class ProductsController < ApplicationController
SHEET = "products"
def index
params = {}
params[:search] = "category:#{params[:category]}" if params[:category].present?
params[:limit] = 50
rows = client.query(SHEET, params)[:data]
@products = rows.map { |row| Product.from_row(row) }
render json: @products
end
def show
rows = client.query(SHEET, search: "sku:#{params[:id]}", limit: 1)[:data]
return render json: { error: "Not found" }, status: :not_found if rows.empty?
render json: Product.from_row(rows.first)
end
private
def client
@client ||= SheetsApiClient.new(
user_key: Rails.application.credentials.gkit_user_key,
api_key: Rails.application.credentials.gkit_api_key
)
end
endWire the routes:
# config/routes.rb
resources :products, only: [:index, :show]GET /products?category=peripherals passes search=category:peripherals to the API and returns only matching rows. GET /products/WM-001 searches by SKU and returns a single product or a 404.
Add caching to avoid hammering the API
Sheet data does not change on every request. Wrapping the API call in Rails.cache.fetch reduces latency and keeps you well within rate limits. A 5-minute TTL is a reasonable default for a product catalogue.
def index
search_params = {}
search_params[:search] = "category:#{params[:category]}" if params[:category].present?
search_params[:limit] = 50
cache_key = "products/index/#{search_params.to_json}"
@products = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
rows = client.query(SHEET, search_params)[:data]
rows.map { |row| Product.from_row(row) }
end
render json: @products
endMake sure your cache store is configured in config/environments/production.rb. The default :memory_store works in development; use :redis_cache_store or :solid_cache_store in production.
Storing credentials
Never hardcode the API key. Add it to Rails' encrypted credentials:
rails credentials:editgkit_user_key: your_user_key_here
gkit_api_key: sk_live_xxxxxxxxxxxxxxxxxxxxReference them in code as Rails.application.credentials.gkit_api_key. If you prefer environment variables, ENV.fetch("GKIT_API_KEY") is equally fine - just stay consistent.
What you have now
A Rails app that treats a Google Sheet as a queryable data source: filtering by column value, fetching single rows by a unique key, and caching responses to keep things fast. The sheet owner can update the catalogue in Google Sheets and the API reflects the change within the cache TTL - no deploy, no database migration.
This pattern works for any sheet: swap products for faq, events, pricing, or whatever sheet name you have. The SheetsApiClient is reusable across controllers, and the Struct pattern keeps the domain model explicit.
If you haven't set up a GKit API key yet, sign up at gkit.io - the free tier covers enough reads to build and test this pattern end to end.