# How to Store JSON Data in Supabase

- Tool: Supabase
- Difficulty: Beginner
- Time required: 10-15 min
- Compatibility: Supabase (all plans), @supabase/supabase-js v2+, PostgreSQL 14+
- Last updated: March 2026

## TL;DR

Supabase uses PostgreSQL's native jsonb column type to store JSON data. Create a column with the jsonb type, insert JSON objects directly using the Supabase JS client, and query nested fields with the -> and ->> operators in SQL or the arrow syntax in the JS client. Index frequently queried JSON paths with GIN indexes for fast lookups.

## Storing and Querying JSON Data in Supabase

PostgreSQL's jsonb type lets you store structured JSON data alongside relational columns. This is useful for flexible schemas like user preferences, metadata, form responses, or any data that varies in structure between rows. This tutorial covers creating jsonb columns, inserting JSON data, querying nested fields, and adding indexes for performance.

## Before you start

- A Supabase project with access to the SQL Editor
- Basic understanding of JSON structure
- @supabase/supabase-js installed in your project

## Step-by-step guide

### 1. Create a table with a jsonb column

Use the SQL Editor in the Supabase Dashboard to create a table with a jsonb column. The jsonb type stores JSON in a binary format that supports indexing and efficient querying. Always prefer jsonb over json — it is faster for reads, supports indexing, and removes duplicate keys. You can also add a jsonb column to an existing table with ALTER TABLE.

```
-- Create a new table with a jsonb column
create table public.products (
  id bigint generated always as identity primary key,
  name text not null,
  metadata jsonb default '{}',
  created_at timestamptz default now()
);

-- Enable RLS (required for any table accessed via the API)
alter table public.products enable row level security;

-- Allow authenticated users to read products
create policy "Authenticated users can read products"
  on public.products for select
  to authenticated
  using (true);
```

**Expected result:** A products table with a jsonb metadata column is created with RLS enabled.

### 2. Insert JSON data using the Supabase JS client

Pass a JavaScript object to the jsonb column and Supabase automatically serializes it to JSON. You can store nested objects, arrays, numbers, strings, and booleans. There is no need to call JSON.stringify — the client handles serialization. You can also insert multiple rows with different JSON structures in the same column since jsonb is schemaless.

```
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)

// Insert a product with JSON metadata
const { data, error } = await supabase
  .from('products')
  .insert({
    name: 'Wireless Keyboard',
    metadata: {
      brand: 'Logitech',
      color: 'black',
      weight_grams: 450,
      features: ['bluetooth', 'rechargeable', 'backlit'],
      dimensions: { width: 43, height: 2, depth: 13 }
    }
  })
  .select()
```

**Expected result:** A new product row is inserted with the full JSON metadata object stored in the metadata column.

### 3. Update specific JSON fields without overwriting the entire object

To update individual fields inside a jsonb column without replacing the whole object, use the PostgreSQL jsonb concatenation operator (||) via an RPC function, or use the Supabase client's update method with a merged object. The simplest approach for the JS client is to read the current value, merge locally, and write back. For atomic updates, create a database function that uses the || operator.

```
-- SQL function for atomic JSON field update
create or replace function update_product_metadata(
  product_id bigint,
  new_fields jsonb
)
returns void
language plpgsql
security invoker
as $$
begin
  update public.products
  set metadata = metadata || new_fields
  where id = product_id;
end;
$$;

-- Call from JavaScript
const { data, error } = await supabase.rpc('update_product_metadata', {
  product_id: 1,
  new_fields: { color: 'white', on_sale: true }
})
```

**Expected result:** The metadata column is updated with the new fields merged in, preserving all existing fields that were not specified.

### 4. Query JSON fields using arrow operators

PostgreSQL provides two operators for accessing JSON fields: -> returns a JSON value and ->> returns a text value. Use -> for traversing nested objects and ->> for the final value you want to compare. In the Supabase JS client, you can filter on JSON fields using the arrow syntax in column names.

```
-- SQL: Query products by a JSON field
select name, metadata->>'brand' as brand
from public.products
where metadata->>'brand' = 'Logitech';

-- SQL: Query nested JSON fields
select name, metadata->'dimensions'->>'width' as width
from public.products
where (metadata->'dimensions'->>'width')::int > 40;

-- JavaScript: Filter by JSON field
const { data, error } = await supabase
  .from('products')
  .select('name, metadata->brand')
  .eq('metadata->>brand', 'Logitech')

// JavaScript: Check if JSON array contains a value
const { data, error } = await supabase
  .from('products')
  .select('*')
  .contains('metadata->features', '["bluetooth"]')
```

**Expected result:** Queries return only products matching the specified JSON field values, with nested fields accessible through chained arrow operators.

### 5. Add GIN indexes on frequently queried JSON paths

Without indexes, querying JSON fields requires a full table scan. PostgreSQL's GIN (Generalized Inverted Index) is optimized for jsonb and supports containment operators (@>, ?, ?&, ?|). Create a GIN index on the entire jsonb column for general queries, or create a btree index on a specific extracted path for exact-match queries on a single field.

```
-- GIN index on the entire jsonb column (supports @> containment queries)
create index idx_products_metadata on public.products using gin (metadata);

-- Btree index on a specific JSON path (faster for exact-match queries)
create index idx_products_brand on public.products
  using btree ((metadata->>'brand'));

-- Now this query uses the GIN index:
select * from public.products
where metadata @> '{"brand": "Logitech"}';

-- And this query uses the btree index:
select * from public.products
where metadata->>'brand' = 'Logitech';
```

**Expected result:** Queries on indexed JSON paths use index scans instead of sequential scans, significantly improving performance on large tables.

## Complete code example

File: `json-data-example.ts`

```typescript
// Complete example: Storing and querying JSON data in Supabase
import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)

// Insert a product with JSON metadata
async function createProduct() {
  const { data, error } = await supabase
    .from('products')
    .insert({
      name: 'Wireless Keyboard',
      metadata: {
        brand: 'Logitech',
        color: 'black',
        weight_grams: 450,
        features: ['bluetooth', 'rechargeable', 'backlit'],
        dimensions: { width: 43, height: 2, depth: 13 }
      }
    })
    .select()
  return { data, error }
}

// Query products by JSON field
async function getProductsByBrand(brand: string) {
  const { data, error } = await supabase
    .from('products')
    .select('id, name, metadata->brand')
    .eq('metadata->>brand', brand)
  return { data, error }
}

// Update a JSON field atomically via RPC
async function updateProductMetadata(
  productId: number,
  fields: Record<string, unknown>
) {
  const { data, error } = await supabase.rpc('update_product_metadata', {
    product_id: productId,
    new_fields: fields
  })
  return { data, error }
}

// Query products containing a specific feature
async function getProductsWithFeature(feature: string) {
  const { data, error } = await supabase
    .from('products')
    .select('*')
    .contains('metadata->features', JSON.stringify([feature]))
  return { data, error }
}
```

## Common mistakes

- **Using the json type instead of jsonb, which does not support indexing or efficient queries** — undefined Fix: Always use jsonb for new columns. If you have existing json columns, migrate them: ALTER TABLE products ALTER COLUMN metadata TYPE jsonb USING metadata::jsonb;
- **Calling JSON.stringify() on objects before inserting them, resulting in double-encoded strings** — undefined Fix: Pass JavaScript objects directly to the Supabase client. It handles serialization automatically. JSON.stringify would store the data as a JSON string literal instead of a JSON object.
- **Not adding indexes on JSON paths used in WHERE clauses, causing slow queries on large tables** — undefined Fix: Add a GIN index for general containment queries or a btree index on the specific extracted path: CREATE INDEX idx_name ON table USING btree ((column->>'field'));

## Best practices

- Use jsonb instead of json for better performance, indexing support, and duplicate key handling
- Set a default value of '{}' on jsonb columns to avoid null handling complexity
- Use -> for traversing JSON objects and ->> for extracting the final text value for comparisons
- Add GIN indexes on jsonb columns that are frequently queried with containment operators
- Create database functions with the || operator for atomic JSON field updates instead of read-modify-write patterns
- Keep JSON structures relatively flat — deeply nested data is harder to query, index, and validate
- Consider using separate relational columns for fields that are always present and frequently filtered
- Enable RLS on tables with jsonb columns and write policies that consider the JSON content if needed

## Frequently asked questions

### What is the difference between json and jsonb in PostgreSQL?

The json type stores data as a text string and parses it on every access. The jsonb type stores data in a binary format that supports indexing, is faster for reads, and automatically removes duplicate keys. Always use jsonb in Supabase.

### Is there a size limit for jsonb columns in Supabase?

PostgreSQL supports jsonb values up to 1 GB per field. However, very large JSON objects degrade query performance. For large data, consider using Supabase Storage for files and storing only references in the database.

### Can I validate the structure of JSON data before inserting?

PostgreSQL does not enforce JSON schemas natively. You can use a check constraint with a database function to validate structure, or validate on the client side before inserting. Supabase Edge Functions are a good place for server-side validation.

### How do I remove a key from a jsonb object?

Use the - operator in SQL: UPDATE products SET metadata = metadata - 'key_to_remove' WHERE id = 1; For nested keys, use jsonb_set with null or the #- operator for path-based removal.

### Can I use jsonb with RLS policies?

Yes, you can reference jsonb fields in RLS policies. For example: USING (metadata->>'owner_id' = (select auth.uid())::text). However, this is slower than using a dedicated column for access control.

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

Use separate columns for data that is always present, frequently filtered, and has a fixed schema. Use jsonb for optional metadata, user preferences, or data that varies between rows. Combining both approaches in the same table is common and recommended.

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

Yes, RapidDev can help you design an optimal schema that balances relational columns for performance-critical data with jsonb columns for flexible metadata, including proper indexing and RLS policies.

---

Source: https://www.rapidevelopers.com/supabase-tutorial/how-to-store-json-data-in-supabase
© RapidDev — https://www.rapidevelopers.com/supabase-tutorial/how-to-store-json-data-in-supabase
