JSON Fields in Strapi: Content Types, API Responses, and TypeScript

Last updated:

Strapi is a Node.js headless CMS that stores content in a database (PostgreSQL, MySQL, or SQLite) and exposes it via a REST and GraphQL API. The JSON field type in a Strapi content type stores any JSON-serializable value — objects, arrays, strings, numbers, or null — in a TEXT column (SQLite/MySQL) or JSONB column (PostgreSQL). Strapi's REST API returns JSON fields as parsed JavaScript values in the response body. Filter on JSON fields with the Strapi filter syntax: ?filters[metadata][plan][$eq]=pro uses dot notation to traverse JSON. Strapi's Content Type Builder (admin UI) and schema.json files define content types — add a JSON field by setting "type": "json" in the component schema. For TypeScript, the @strapi/strapi package exports Attribute.JSON for type definitions. Validate JSON field values in lifecycle hooks with Zod: the beforeCreate and beforeUpdate hooks run server-side before every write. This guide covers Strapi JSON field configuration, REST API filter syntax, lifecycle hook validation, TypeScript attribute types, the JavaScript SDK, and plugin JSON configuration.

Adding a JSON Field to a Content Type

Bottom line: define JSON fields in src/api/{content-type}/content-types/{content-type}/schema.json with "type": "json". The Content Type Builder generates the same file — both approaches produce identical results.

When you add "type": "json" to a content type attribute, Strapi stores the value as a JSON string in the database and returns it as a parsed JavaScript value in API responses. There is no schema validation at the database level — the database accepts any value without complaint. To enforce structure, use a lifecycle hook with Zod (covered in section 4). Mark a JSON field required with "required": true — Strapi rejects writes with a 400 error if the field is missing or null. Strapi JSON fields do not support a "default" key in the schema; use a beforeCreate hook to set defaults programmatically. The key design choice between "type": "json" and a Component is flexibility vs. structure: a Component gives each attribute its own typed field in the schema and in the admin UI; a JSON field stores a single untyped blob — useful when the structure changes per entry or is defined by an external system.

// src/api/product/content-types/product/schema.json
{
  "kind": "collectionType",
  "collectionName": "products",
  "info": {
    "singularName": "product",
    "pluralName": "products",
    "displayName": "Product"
  },
  "options": { "draftAndPublish": true },
  "attributes": {
    "name": {
      "type": "string",
      "required": true
    },
    "price": {
      "type": "decimal",
      "required": true
    },
    "metadata": {
      "type": "json",
      "required": true
      // No "default" key — use beforeCreate hook instead
    },
    "tags": {
      "type": "json"
      // Optional — stores an array like ["featured", "sale"]
    }
  }
}

// Content Type Builder UI equivalent:
// Admin → Content-Type Builder → Product → Add another field
// → Select "JSON" from field type list → Name: "metadata" → Save

The generated TypeScript attribute type (after running yarn strapi ts:generate-types):

import type { Attribute } from '@strapi/strapi'

// Auto-generated in .strapi/types/
export interface Product extends Attribute.ContentType {
  name:     Attribute.String & Attribute.Required
  price:    Attribute.Decimal & Attribute.Required
  metadata: Attribute.JSON & Attribute.Required
  tags:     Attribute.JSON
}

// Narrow the JSON type with a generic:
// metadata: Attribute.JSON<ProductMetadata> & Attribute.Required

REST API Responses and JSON Field Structure

Bottom line: Strapi REST returns JSON fields as parsed JavaScript objects — no JSON.parse() needed. The response shape differs between v4 (nested attributes) and v5 (flat top-level fields).

In Strapi v4, every entry is wrapped in { data: { id, documentId, attributes: { ... } }, meta: {} }. In Strapi v5, the wrapper is removed and fields sit directly on the entry object: { data: [{ id, documentId, name, metadata }] }. This flattening is a breaking change in v5 — client code that accesses entry.attributes.metadata in v4 must change to entry.metadata in v5. JSON fields are always included in the response regardless of populate parameters — they are scalar fields, not relations. Use ?fields[0]=name&fields[1]=metadata to request only specific fields and reduce response size. Sorting by a JSON subfield (e.g. ?sort[0]=metadata.score:desc) works only on PostgreSQL, which supports JSONB path operators natively.

// GET /api/products?populate=*

// Strapi v4 response shape:
{
  "data": {
    "id": 1,
    "documentId": "abc123",
    "attributes": {
      "name": "Widget Pro",
      "price": 49.99,
      "metadata": { "plan": "pro", "score": 92, "tags": ["featured"] },
      "createdAt": "2026-05-27T10:00:00.000Z"
    }
  },
  "meta": {}
}

// Strapi v5 response shape (flattened — no "attributes" wrapper):
{
  "data": [
    {
      "id": 1,
      "documentId": "abc123",
      "name": "Widget Pro",
      "price": 49.99,
      "metadata": { "plan": "pro", "score": 92, "tags": ["featured"] },
      "createdAt": "2026-05-27T10:00:00.000Z"
    }
  ],
  "meta": { "pagination": { "page": 1, "pageSize": 25, "total": 1 } }
}
// Fetch with native fetch — JSON field is already parsed
const res = await fetch(
  'https://api.example.com/api/products' +
  '?fields[0]=name&fields[1]=metadata' +
  '&sort[0]=metadata.score:desc',   // PostgreSQL only
  { headers: { Authorization: 'Bearer YOUR_API_TOKEN' } }
)
const { data } = await res.json()

// Strapi v5: field is at top level
const score = data[0].metadata.score   // 92 — no JSON.parse() needed

// Strapi v4: field is under attributes
// const score = data[0].attributes.metadata.score

Filtering on JSON Fields

Bottom line: Strapi filter syntax uses bracket notation to traverse nested JSON. PostgreSQL supports all filter operators on JSON subfields; SQLite and MySQL do not have native JSON path operators.

The filter query string uses the pattern ?filters[field][subfield][$operator]=value. For a top-level JSON key: ?filters[metadata][plan][$eq]=pro. For a numeric comparison: ?filters[metadata][score][$gte]=80. For case-insensitive substring match: ?filters[metadata][tags][$containsi]=featured. Compound filters use $and or $or at the top level. The filter operators available for JSON subfields mirror the standard Strapi operators: $eq, $ne, $lt, $lte, $gt, $gte, $in, $notIn, $contains, $startsWith, and $containsi. On SQLite and MySQL, Strapi lacks the JSON path operators needed to target a specific subfield — attempting these filters on non-PostgreSQL databases may return no results or throw a database error.

// Filter: metadata.plan === "pro"
GET /api/products?filters[metadata][plan][$eq]=pro

// Filter: metadata.score >= 80
GET /api/products?filters[metadata][score][$gte]=80

// Filter: metadata.tags contains "featured" (case-insensitive)
GET /api/products?filters[metadata][tags][$containsi]=featured

// Combined filter: plan === "pro" AND active === true
GET /api/products
  ?filters[$and][0][metadata][plan][$eq]=pro
  &filters[$and][1][metadata][active][$eq]=true

// Using fetch:
const params = new URLSearchParams({
  'filters[metadata][plan][$eq]': 'pro',
  'filters[metadata][score][$gte]': '80',
  'sort[0]': 'metadata.score:desc',
})
const res = await fetch(
  `https://api.example.com/api/products?${params}`,
  { headers: { Authorization: 'Bearer YOUR_TOKEN' } }
)

// ⚠ Database compatibility note:
// PostgreSQL → all JSON path filters work (JSONB operators)
// MySQL      → JSON filters may perform full-table string scan
// SQLite     → JSON subfield filters likely fail or return wrong results

Lifecycle Hook Validation with Zod

Bottom line: place a lifecycles.ts file at the correct path and Strapi registers it automatically — no additional wiring needed. Use beforeCreate and beforeUpdate with Zod safeParse to reject invalid JSON field values before they reach the database.

Lifecycle hooks are the only place to enforce a JSON field schema server-side in Strapi. The beforeCreate hook receives { params } where params.data contains the incoming document fields. Access the JSON field value at params.data.metadata. Validate with Zod safeParse — which never throws — and then throw a Strapi ValidationError if the result fails. ValidationError produces a 400 HTTP response with a structured error body, including field-level messages from Zod's flatten(). The afterFindMany hook runs after database reads and receives an array of results — use it to transform or redact JSON fields before they reach the API response, e.g. to strip internal keys or add computed properties.

// src/api/product/content-types/product/lifecycles.ts
// Strapi auto-registers this file — no import or registration needed

import { z } from 'zod'
import { errors } from '@strapi/utils'

// Define the expected JSON field shape
const ProductMetadataSchema = z.object({
  plan:   z.enum(['free', 'pro', 'enterprise']),
  score:  z.number().min(0).max(100),
  tags:   z.array(z.string()).max(10),
  active: z.boolean(),
})

type ProductMetadata = z.infer<typeof ProductMetadataSchema>

export default {
  async beforeCreate({ params }: { params: { data: Record<string, unknown> } }) {
    const result = ProductMetadataSchema.safeParse(params.data.metadata)

    if (!result.success) {
      // ValidationError → 400 response with structured Zod errors
      throw new errors.ValidationError(
        'Invalid metadata field',
        result.error.flatten()
      )
    }

    // Optionally mutate: set defaults or normalize values
    params.data.metadata = {
      ...result.data,
      active: result.data.active ?? true,
    } satisfies ProductMetadata
  },

  async beforeUpdate({ params }: { params: { data: Record<string, unknown> } }) {
    // Only validate if metadata is being updated
    if (params.data.metadata === undefined) return

    const result = ProductMetadataSchema.safeParse(params.data.metadata)
    if (!result.success) {
      throw new errors.ValidationError(
        'Invalid metadata field',
        result.error.flatten()
      )
    }
  },

  async afterFindMany({ result }: { result: Array<Record<string, unknown>> }) {
    // Transform JSON fields in the response — e.g. remove internal keys
    for (const entry of result) {
      if (entry.metadata && typeof entry.metadata === 'object') {
        const meta = entry.metadata as Record<string, unknown>
        delete meta['internalKey']   // strip before sending to client
      }
    }
  },
}

TypeScript Types with @strapi/strapi

Bottom line: run yarn strapi ts:generate-types after every schema change. Narrow the generated Attribute.JSON type with the Attribute.JSON<T> generic. Use the @strapi/client SDK for typed API calls from external clients.

Strapi v5 generates TypeScript types automatically on server start and when you run the generate command. Types land in .strapi/types/ and are referenced from your source code via @strapi/strapi augmentation. The generated ProductAttributes interface includes each content type attribute — JSON fields appear as Attribute.JSON by default. To narrow the type, add the generic parameter: Attribute.JSON<ProductMetadata> — TypeScript then knows the exact shape of the field at compile time. The Strapi JavaScript SDK (@strapi/client, v5) connects to a Strapi server from external Node.js or browser code. It exposes find, findOne, create, update, and delete with TypeScript generics for the response shape.

// Refresh generated types after schema changes:
// yarn strapi ts:generate-types
// Types written to: .strapi/types/generated/components.d.ts

// --- Manually narrow the JSON field type ---
// src/types/product.ts
import type { Attribute } from '@strapi/strapi'

interface ProductMetadata {
  plan:   'free' | 'pro' | 'enterprise'
  score:  number
  tags:   string[]
  active: boolean
}

export interface ProductAttributes {
  name:     Attribute.String & Attribute.Required
  price:    Attribute.Decimal & Attribute.Required
  // Narrow Attribute.JSON with a generic:
  metadata: Attribute.JSON<ProductMetadata> & Attribute.Required
  tags:     Attribute.JSON<string[]>
}

// --- @strapi/client SDK (v5) from external client ---
import { createClient } from '@strapi/client'

const strapi = createClient({
  baseURL: 'https://api.example.com',
  auth:    { type: 'api-token', token: process.env.STRAPI_API_TOKEN! },
})

// Typed find — response.data is Product[]
interface Product {
  id:         number
  documentId: string
  name:       string
  metadata:   ProductMetadata
}

const response = await strapi.find<Product>('products', {
  filters: { metadata: { plan: { $eq: 'pro' } } },
  sort:    ['metadata.score:desc'],
  fields:  ['name', 'metadata'],
})
// response.data[0].metadata.plan → 'pro'  (fully typed)

// Typed findOne:
const single = await strapi.findOne<Product>('products', 'doc-id-abc123')
// single.data.metadata.score → number
// --- Document Service (Strapi v5 server-side API) ---
// Use inside Strapi controllers, services, or plugins

// Find with filters (server-side):
const products = await strapi
  .documents('api::product.product')
  .findMany({
    filters: { metadata: { plan: { $eq: 'pro' } } },
    sort:    ['metadata.score:desc'],
  })

// Create a product with JSON field:
const newProduct = await strapi
  .documents('api::product.product')
  .create({
    data: {
      name:     'Widget Pro',
      price:    49.99,
      metadata: { plan: 'pro', score: 90, tags: ['featured'], active: true },
    },
  })

Plugin JSON Configuration

Bottom line: Strapi plugin configuration is a JavaScript object in config/plugins.ts. Access any config value at runtime with strapi.config.get('plugin.my-plugin.key'). Environment-specific overrides live in config/env/{production}/plugins.ts.

Strapi's config system merges 3 layers in order: base config files in config/, environment-specific files in config/env/{NODE_ENV}/ , and environment variables. Environment variables always win. The config/plugins.ts file exports a plain JavaScript object — not JSON — which means you can reference process.env values directly. Middleware configuration follows the same pattern in config/middlewares.ts as an ordered array of middleware identifiers and options objects. The src/admin/app.ts file handles admin UI customization with a similar JSON-like config object. Custom plugins access their config via strapi.config.get(), which traverses dot-separated keys: 'plugin.my-plugin.setting' maps to the setting key under the my-plugin object in config/plugins.ts.

// config/plugins.ts — base configuration for all environments
export default {
  upload: {
    config: {
      provider: 's3',
      providerOptions: {
        accessKeyId:     process.env.AWS_ACCESS_KEY_ID,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
        region:          process.env.AWS_REGION ?? 'us-east-1',
        params: {
          ACL:    'public-read',
          Bucket: process.env.AWS_BUCKET,
        },
      },
      actionOptions: {
        upload:       { ACL: null },
        uploadStream: { ACL: null },
        delete:       {},
      },
    },
  },
  'my-plugin': {
    enabled: true,
    config: {
      apiUrl:  'https://service.example.com',
      timeout: 5000,
      retry:   3,
    },
  },
}

// config/env/production/plugins.ts — production-only overrides
export default {
  'my-plugin': {
    config: {
      apiUrl: process.env.MY_PLUGIN_API_URL,   // env var override
    },
  },
}
// config/middlewares.ts — ordered middleware array
export default [
  'strapi::logger',
  'strapi::errors',
  {
    name:   'strapi::security',
    config: {
      contentSecurityPolicy: {
        useDefaults: true,
        directives: {
          'connect-src':  ["'self'", 'https:'],
          'img-src':      ["'self'", 'data:', 'blob:', 'https://cdn.example.com'],
        },
      },
    },
  },
  'strapi::cors',
  'strapi::poweredBy',
  'strapi::query',
  'strapi::body',
  'strapi::session',
  'strapi::favicon',
  'strapi::public',
]

// Access plugin config in a custom plugin:
// src/plugins/my-plugin/server/register.ts
export default ({ strapi }: { strapi: Strapi }) => {
  const apiUrl  = strapi.config.get('plugin.my-plugin.apiUrl')   // string
  const timeout = strapi.config.get('plugin.my-plugin.timeout')  // 5000
  const retry   = strapi.config.get('plugin.my-plugin.retry')    // 3

  // STRAPI_ADMIN_JWT_SECRET env var overrides config/admin.ts jwtSecret:
  // export default { auth: { secret: process.env.STRAPI_ADMIN_JWT_SECRET } }
}

FAQ

How do I add a JSON field to a Strapi content type?

Edit src/api/{content-type}/content-types/{content-type}/schema.json and add an attribute with "type": "json". Or use the Content Type Builder in the admin UI — both produce the same schema.json output. To make the field required, add "required": true. Strapi does not support "default" on JSON fields — use a beforeCreate lifecycle hook to set default values. After saving, Strapi restarts and runs a database migration. For structured data with individual typed fields, consider a Component instead of a JSON field.

How does Strapi return JSON field data in the API?

Strapi REST returns JSON fields as parsed JavaScript objects — no JSON.parse() needed. In Strapi v4, the field is at data.attributes.metadata. In Strapi v5, it is at data[0].metadata (flattened response). JSON fields are always returned — you do not need ?populate=metadata. Use ?fields[0]=name&fields[1]=metadata to select specific fields. Sort by a JSON subfield with ?sort[0]=metadata.score:desc — this requires PostgreSQL.

How do I filter on a JSON field in Strapi?

Use bracket notation: ?filters[metadata][plan][$eq]=pro matches entries where metadata.plan equals "pro". Numeric operators work the same way: ?filters[metadata][score][$gte]=80. For case-insensitive string search: ?filters[metadata][tags][$containsi]=featured. Combine with ?filters[$and][0]...[1].... JSON subfield filtering requires PostgreSQL — SQLite and MySQL lack native JSON path operators and may silently fail or do full-table scans.

How do I validate a Strapi JSON field with Zod?

Create src/api/product/content-types/product/lifecycles.ts — Strapi registers it automatically at that path. In the beforeCreate hook, call ProductMetadataSchema.safeParse(params.data.metadata). If it fails, throw new errors.ValidationError('Invalid metadata', result.error.flatten()) from @strapi/utils — this produces a 400 response with structured field errors. Use both beforeCreate and beforeUpdate to cover all write paths.

How do I add TypeScript types to Strapi JSON fields?

Run yarn strapi ts:generate-types after schema changes — types land in .strapi/types/. JSON fields appear as Attribute.JSON by default. Narrow the type with the generic: Attribute.JSON<ProductMetadata>. For the SDK, use strapi.find<Product>("products") — the generic applies to the full response shape. Access auto-generated types from @strapi/strapi.

How do I configure Strapi plugins with JSON?

Export a plain object from config/plugins.ts with a key per plugin. Each plugin reads its config via strapi.config.get('plugin.my-plugin.key'). Override per environment in config/env/production/plugins.ts. Middleware configuration goes in config/middlewares.ts as an ordered array. Environment variables (e.g. STRAPI_ADMIN_JWT_SECRET) override the corresponding config values at runtime.

Working with other databases or backend APIs?

The same JSON field patterns — storing flexible data, filtering, and validating with Zod — apply across different backends. See the equivalent guides for JSON in Firebase, JSON in MongoDB, JSON in Supabase, and JSON API design.

Open Jsonic JSON Tools

Further reading and primary sources

  • Strapi Content Type Builder — field typesOfficial Strapi documentation on model attributes including the JSON field type, required, and attribute options
  • Strapi REST API filtersOfficial Strapi REST API filter documentation covering bracket notation, operators, and combining filters
  • Strapi lifecycle hooksOfficial Strapi documentation on beforeCreate, beforeUpdate, afterFindMany, and all lifecycle hook signatures
  • Strapi TypeScript supportOfficial Strapi TypeScript guide including ts:generate-types, Attribute generics, and type augmentation patterns
  • @strapi/client SDKOfficial documentation for the Strapi JavaScript SDK: createClient, find, findOne, create, update, and TypeScript generics
  • Strapi plugin configurationOfficial Strapi documentation on config/plugins.ts, environment-specific configs, and strapi.config.get()