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" → SaveThe 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.RequiredREST 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.scoreFiltering 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 resultsLifecycle 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 ToolsFurther reading and primary sources
- Strapi Content Type Builder — field types — Official Strapi documentation on model attributes including the JSON field type, required, and attribute options
- Strapi REST API filters — Official Strapi REST API filter documentation covering bracket notation, operators, and combining filters
- Strapi lifecycle hooks — Official Strapi documentation on beforeCreate, beforeUpdate, afterFindMany, and all lifecycle hook signatures
- Strapi TypeScript support — Official Strapi TypeScript guide including ts:generate-types, Attribute generics, and type augmentation patterns
- @strapi/client SDK — Official documentation for the Strapi JavaScript SDK: createClient, find, findOne, create, update, and TypeScript generics
- Strapi plugin configuration — Official Strapi documentation on config/plugins.ts, environment-specific configs, and strapi.config.get()