Skip to main content
RapidDev - Software Development Agency
supabase-tutorial

How to Query JSON Fields in Supabase

Supabase stores JSON data in PostgreSQL jsonb columns, which you can query using both the JavaScript client and raw SQL. The JS client uses arrow notation in select() to extract nested values, and filter methods like .eq() and .contains() to query inside JSON objects. In SQL, use -> to get a JSON element and ->> to get a text value. Add GIN indexes on jsonb columns for fast lookups on nested fields.

What you'll learn

  • How to select nested JSON values with the Supabase JS client
  • How to filter rows by JSON field values
  • How to use PostgreSQL JSON operators in raw SQL
  • How to index jsonb columns for fast queries
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner8 min read10-15 minSupabase (all plans), @supabase/supabase-js v2+, PostgreSQL 14+March 2026RapidDev Engineering Team
TL;DR

Supabase stores JSON data in PostgreSQL jsonb columns, which you can query using both the JavaScript client and raw SQL. The JS client uses arrow notation in select() to extract nested values, and filter methods like .eq() and .contains() to query inside JSON objects. In SQL, use -> to get a JSON element and ->> to get a text value. Add GIN indexes on jsonb columns for fast lookups on nested fields.

Querying JSONB Data in Supabase

PostgreSQL's jsonb type lets you store flexible, schema-less data alongside your structured columns. Supabase exposes powerful JSONB query capabilities through both its JavaScript client and SQL. This tutorial shows you how to select specific fields from JSON columns, filter rows based on nested values, and add indexes to keep JSON queries fast as your data grows.

Prerequisites

  • A Supabase project with a table containing a jsonb column
  • @supabase/supabase-js v2+ installed in your project
  • Basic understanding of JSON structure and PostgreSQL data types

Step-by-step guide

1

Create a table with a jsonb column

Before querying JSON data, you need a table with a jsonb column. The jsonb type stores JSON in a decomposed binary format that is faster to query than the plain json type. Use jsonb for any column that will be filtered or indexed. Add some sample data with nested objects to practice querying.

typescript
1-- Create a table with a jsonb metadata column
2CREATE TABLE products (
3 id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
4 name text NOT NULL,
5 metadata jsonb DEFAULT '{}',
6 created_at timestamptz DEFAULT now()
7);
8
9-- Enable RLS
10ALTER TABLE products ENABLE ROW LEVEL SECURITY;
11
12-- Allow authenticated users to read
13CREATE POLICY "Authenticated users can read products"
14 ON products FOR SELECT TO authenticated USING (true);
15
16-- Insert sample data with nested JSON
17INSERT INTO products (name, metadata) VALUES
18 ('Widget A', '{"color": "red", "weight": 1.5, "tags": ["sale", "new"], "dimensions": {"width": 10, "height": 5}}'),
19 ('Widget B', '{"color": "blue", "weight": 2.0, "tags": ["popular"], "dimensions": {"width": 15, "height": 8}}'),
20 ('Widget C', '{"color": "red", "weight": 0.8, "tags": ["sale", "popular"], "dimensions": {"width": 8, "height": 3}}');

Expected result: A products table with sample JSON data in the metadata column.

2

Select nested JSON values with the JS client

The Supabase JS client lets you extract specific JSON fields in the select() method using arrow notation. Use -> to navigate into nested objects. Supabase translates this into PostgreSQL's -> and ->> operators behind the scenes. You can select multiple nested fields and alias them for cleaner results.

typescript
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
6)
7
8// Select specific JSON fields
9const { data } = await supabase
10 .from('products')
11 .select('name, metadata->color, metadata->weight')
12
13// Select nested fields (dimensions.width)
14const { data: nested } = await supabase
15 .from('products')
16 .select('name, metadata->dimensions->width')
17
18// Result: [{ name: 'Widget A', width: 10 }, ...]

Expected result: Each row contains only the selected JSON fields alongside the regular columns.

3

Filter rows by JSON field values

You can filter rows based on values inside JSON columns using the standard filter methods. Use the arrow path syntax to reference nested fields in .eq(), .gt(), .lt(), and other filters. For checking if a JSON array contains a value, use the .contains() method. For checking if a JSON object contains a specific key-value pair, also use .contains() with a JSON object.

typescript
1// Filter by a top-level JSON field
2const { data: redProducts } = await supabase
3 .from('products')
4 .select('*')
5 .eq('metadata->color', '"red"') // Note: JSON string values need quotes
6
7// Filter by a nested JSON field
8const { data: wideProducts } = await supabase
9 .from('products')
10 .select('*')
11 .gt('metadata->weight', 1.0)
12
13// Check if a JSON array contains a value
14const { data: saleProducts } = await supabase
15 .from('products')
16 .select('*')
17 .contains('metadata->tags', '["sale"]')
18
19// Check if JSON contains a key-value pair
20const { data: matched } = await supabase
21 .from('products')
22 .select('*')
23 .contains('metadata', '{"color": "red"}')

Expected result: Only rows matching the JSON field condition are returned.

4

Query JSON with raw SQL operators

For complex JSON queries, use the SQL Editor or supabase.rpc() with PostgreSQL's native JSON operators. The -> operator returns a JSON element, ->> returns a text value, @> checks containment, and ? checks if a key exists. These operators give you full control for queries that the JS client cannot express directly.

typescript
1-- Select a JSON field as text (no quotes)
2SELECT name, metadata->>'color' AS color
3FROM products;
4
5-- Filter by nested value
6SELECT * FROM products
7WHERE metadata->'dimensions'->>'width' > '10';
8
9-- Check containment (does metadata contain this object?)
10SELECT * FROM products
11WHERE metadata @> '{"color": "red"}';
12
13-- Check if a key exists
14SELECT * FROM products
15WHERE metadata ? 'weight';
16
17-- Check if any of these keys exist
18SELECT * FROM products
19WHERE metadata ?| array['color', 'size'];
20
21-- Access array element by index
22SELECT name, metadata->'tags'->>0 AS first_tag
23FROM products;

Expected result: SQL queries return the expected filtered and extracted JSON data.

5

Add GIN indexes for fast JSON queries

Without indexes, JSON queries perform a sequential scan on every row. Add a GIN index on the jsonb column to speed up containment (@>), key existence (?), and array operations. For queries that filter on a specific JSON path, create a targeted btree index on the extracted value for even better performance.

typescript
1-- General-purpose GIN index for @> and ? operators
2CREATE INDEX idx_products_metadata ON products USING gin (metadata);
3
4-- Targeted btree index for a specific field
5-- Best for .eq() and comparison filters on one path
6CREATE INDEX idx_products_color ON products
7 USING btree ((metadata->>'color'));
8
9-- Index for numeric comparisons on a JSON field
10CREATE INDEX idx_products_weight ON products
11 USING btree (((metadata->>'weight')::numeric));
12
13-- Verify the index is being used
14EXPLAIN (ANALYZE)
15SELECT * FROM products
16WHERE metadata @> '{"color": "red"}';

Expected result: EXPLAIN ANALYZE shows Index Scan instead of Seq Scan, with significantly faster query execution.

Complete working example

query-json-fields.ts
1import { createClient } from '@supabase/supabase-js'
2
3const supabase = createClient(
4 process.env.NEXT_PUBLIC_SUPABASE_URL!,
5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
6)
7
8// ---- Selecting JSON fields ----
9
10// Select specific nested values
11async function getProductColors() {
12 const { data, error } = await supabase
13 .from('products')
14 .select('name, metadata->color, metadata->dimensions->width')
15 return { data, error }
16}
17
18// ---- Filtering by JSON values ----
19
20// Filter by JSON string field
21async function getProductsByColor(color: string) {
22 const { data, error } = await supabase
23 .from('products')
24 .select('*')
25 .eq('metadata->color', `"${color}"`)
26 return { data, error }
27}
28
29// Filter by JSON numeric field
30async function getProductsHeavierThan(weight: number) {
31 const { data, error } = await supabase
32 .from('products')
33 .select('*')
34 .gt('metadata->weight', weight)
35 return { data, error }
36}
37
38// Filter by JSON array containment
39async function getProductsWithTag(tag: string) {
40 const { data, error } = await supabase
41 .from('products')
42 .select('*')
43 .contains('metadata->tags', `["${tag}"]`)
44 return { data, error }
45}
46
47// Filter by JSON object containment
48async function getProductsMatching(criteria: Record<string, any>) {
49 const { data, error } = await supabase
50 .from('products')
51 .select('*')
52 .contains('metadata', JSON.stringify(criteria))
53 return { data, error }
54}
55
56// ---- Usage examples ----
57
58async function main() {
59 const colors = await getProductColors()
60 console.log('Colors:', colors.data)
61
62 const redProducts = await getProductsByColor('red')
63 console.log('Red products:', redProducts.data)
64
65 const heavy = await getProductsHeavierThan(1.0)
66 console.log('Heavy products:', heavy.data)
67
68 const onSale = await getProductsWithTag('sale')
69 console.log('On sale:', onSale.data)
70}
71
72main()

Common mistakes when querying JSON Fields in Supabase

Why it's a problem: Forgetting to wrap JSON string values in double quotes when using .eq()

How to avoid: JSON stores strings with quotes. Use .eq('metadata->color', '"red"') with the inner quotes. Numeric values like .gt('metadata->weight', 1.5) do not need quotes.

Why it's a problem: Using the json column type instead of jsonb

How to avoid: Always use jsonb for columns you query or index. The json type stores raw text and cannot be indexed with GIN. Alter existing columns: ALTER TABLE products ALTER COLUMN metadata TYPE jsonb USING metadata::jsonb;

Why it's a problem: Comparing ->> text output to numbers without casting in SQL

How to avoid: The ->> operator returns text. For numeric comparisons, cast explicitly: WHERE (metadata->>'weight')::numeric > 1.5. Without the cast, '9' > '10' because text comparison is alphabetical.

Why it's a problem: Not adding indexes on jsonb columns, leading to full table scans

How to avoid: Add a GIN index for containment queries or a btree index on extracted paths for equality/range queries. Without indexes, every JSON query scans every row.

Best practices

  • Use jsonb instead of json for all columns that need querying or indexing
  • Add a GIN index on the jsonb column for general-purpose containment and key-existence queries
  • Use targeted btree indexes on specific extracted paths for high-frequency equality or range filters
  • Use -> to get JSON elements and ->> to get text values in SQL queries
  • Cast ->> text output to the appropriate type for numeric or date comparisons
  • Keep JSON structure consistent across rows — inconsistent keys make filtering unreliable
  • Consider extracting frequently queried JSON fields into dedicated columns for better performance and type safety

Still stuck?

Copy one of these prompts to get a personalized, step-by-step explanation.

ChatGPT Prompt

I have a Supabase products table with a jsonb metadata column containing color, weight, tags array, and dimensions object. Show me how to query by color, filter by weight range, check if tags contains a value, and access nested dimensions fields using both the JS client and raw SQL.

Supabase Prompt

Write TypeScript functions using the Supabase JS client to select specific fields from a jsonb column, filter by nested JSON values, and check JSON array containment. Include the SQL for creating GIN and btree indexes on the jsonb column.

Frequently asked questions

What is the difference between -> and ->> in PostgreSQL?

The -> operator returns a JSON element (preserving the JSON type), while ->> returns the value as plain text. Use -> for navigating nested objects and ->> when you need the final text value for comparisons or display.

Can I update a single field inside a jsonb column without replacing the entire object?

Yes. Use the jsonb_set function in SQL: UPDATE products SET metadata = jsonb_set(metadata, '{color}', '"green"') WHERE id = 'some-id'. Via the JS client, you currently need to read the full JSON, modify it, and write it back.

Should I use jsonb columns or separate tables for structured data?

Use separate tables for data with a consistent schema that you frequently query and join. Use jsonb for truly flexible or variable data like user preferences, feature flags, or third-party API responses where the structure varies.

Does RLS work on jsonb columns?

Yes. You can reference jsonb fields in RLS policies. For example: USING (metadata->>'tenant_id' = (select auth.jwt()->>'tenant_id')). However, complex JSON operations in RLS policies can slow down every query, so keep policies simple.

How do I query a jsonb array for partial matches?

Use the @> containment operator. For example, to find rows where tags contains 'sale': WHERE metadata->'tags' @> '["sale"]'. This works efficiently with a GIN index on the jsonb column.

Can RapidDev help design my Supabase database schema with JSON columns?

Yes. RapidDev can help you decide where to use jsonb versus relational tables, design proper indexes for JSON queries, and implement efficient query patterns for your specific data model.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help with your project?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.