Metafields (Custom Fields)
Metafields are typed, validated custom fields you can attach to EverShop entities. A metafield definition describes one field — its type, validations, and which entity it belongs to — and each entity stores its values in a meta_data JSONB column. Definitions are ordinary database rows managed at runtime, so — unlike widgets or cron jobs — there is no bootstrap registration and no registry lock.
Overview
The system has two halves:
- Definitions live in the shared
metafield_definitiontable. A definition is keyed by an owner type (product,category, …), a namespace (defaultcustom), and a field key. It carries the field's type, validation rules, and avisible_to_customerflag. - Values live on the owning entity. Every metafield-capable table has a
meta_data jsonb NOT NULL DEFAULT '{}'column shaped as{ namespace: { key: value } }. The store-wideshopowner has no entity table, so its values live in a single-rowmetafield_shoptable.
Because the owner type is an open varchar (no foreign key, no enum), any module or extension can attach metafields to its own entities.
Field types
A definition's type is one of ten values:
| Type | Stored value | Notes |
|---|---|---|
short_text | string | Single-line text |
long_text | string | Multi-line text |
rich_text | block-editor Row[] | The same content format as the CMS text widget |
integer | number | Whole numbers |
number | number | Decimals allowed |
boolean | boolean | True / false |
date | string | YYYY-MM-DD |
color | string | Hex color #RRGGBB |
url | string | A URL |
group | object | A set of sub-fields (see below) |
Any type can be a list by setting isList: true — the stored value becomes an array of that type.
A group bundles sub-fields (each itself a FieldDescriptor) into an object; a group with isList: true stores an array of objects. Group nesting is capped at three levels deep.
Validations
Each field can carry declarative validation rules, enforced server-side with AJV:
| Rule | Applies to | Constrains |
|---|---|---|
size | text | Minimum and maximum string length (min, max) |
range | numeric | Minimum and maximum value (min, max) |
regexp | text | A regular-expression pattern the value must match |
choices | text / numeric | A predefined set of values; renders as a dropdown in the admin |
A validation rule is an object such as { "type": "size", "max": 120 } or { "type": "choices", "values": ["a", "b"] }. Validations also work on group sub-fields — for example, a choices rule on a sub-field renders a select in the admin repeater.
Entities that support metafields
Eight owner types are wired end to end (value column, write path, GraphQL exposure, admin editor, and value cleanup on definition delete):
| Owner type | Value storage | Module |
|---|---|---|
product | product.meta_data | catalog |
category | category.meta_data | catalog |
collection | collection.meta_data | catalog |
customer | customer.meta_data | customer |
order | order.meta_data | oms |
shop | metafield_shop (singleton) | base |
blog_post | blog_post.meta_data | blog |
blog_category | blog_category.meta_data | blog |
A definition can be created for any owner-type string, but only these eight render and edit out of the box. Wiring a new owner type is an extension task (a meta_data column, a write path, a GraphQL field, and a prune subscriber).
Declaring a definition from an extension
Metafield definitions are database rows, not a bootstrap-locked registry — so an extension declares one in a migration, not in bootstrap.ts. (Bootstrap runs before migrations, so the table may not exist yet on a fresh install.) Use INSERT … ON CONFLICT DO NOTHING so the migration is idempotent and never clobbers a merchant's own definition:
import { execute, type PoolClient } from '@evershop/postgres-query-builder';
export default async (connection: PoolClient): Promise<void> => {
await execute(
connection,
`INSERT INTO "metafield_definition"
("owner_type", "namespace", "field_key", "name", "field_type", "visible_to_customer")
VALUES ('product', 'loyalty', 'points', 'Loyalty points', 'integer', TRUE)
ON CONFLICT ("owner_type", "namespace", "field_key") DO NOTHING`
);
};
For programmatic creation at runtime (for example, from a subscriber or an admin action), use the library service. It compiles and validates the descriptor, rejects duplicates, and emits a metafield_definition_created event:
import { createMetafieldDefinition } from '@evershop/evershop/lib/metafield';
await createMetafieldDefinition({
ownerType: 'product',
namespace: 'loyalty',
key: 'points',
name: 'Loyalty points',
type: 'integer',
validations: [{ type: 'range', min: 0 }]
});
Five owner types ship an owner-scoped helper that bakes in the owner type: addProductMetafieldDefinition, addCategoryMetafieldDefinition and addCollectionMetafieldDefinition from @evershop/evershop/catalog/services, addCustomerMetafieldDefinition from @evershop/evershop/customer/services, and addOrderMetafieldDefinition from @evershop/evershop/oms/services. There is no addBlogPostMetafieldDefinition, addBlogCategoryMetafieldDefinition or publicly-exported addShopMetafieldDefinition — for those owners, call createMetafieldDefinition with an explicit ownerType.
import { addProductMetafieldDefinition } from '@evershop/evershop/catalog/services';
await addProductMetafieldDefinition({
namespace: 'loyalty',
key: 'points',
name: 'Loyalty points',
type: 'integer'
});
ownerType, namespace, key, type, and isList are immutable after creation — changing any of them would orphan the stored values. name, description, validations, and visibleToCustomer can be updated.
Merchants can also create and edit definitions with no code, from the Custom fields card that appears on each entity's edit page in the admin.
Writing values
Products, categories, collections, and blog entities accept a metafields key in their ordinary create/update API payload; it is folded into the meta_data column on save:
{
"name": "Ethiopia Yirgacheffe",
"metafields": {
"loyalty": { "points": 50 }
}
}
Customers, orders, and the shop are edit-only through dedicated endpoints — PATCH /api/customers/:id/metafields, PATCH /api/orders/:id/metafields, and PATCH /api/shop/metafields — each taking { "metafields": { … } }.
Some owners also expose service functions for extension code — setProductMetafields(id, values) writes the full set, and setProductMetafield(id, namespace, key, value) writes a single field (a blank value removes the key).
| Owner type | Service functions | Import path |
|---|---|---|
product, category, collection | set<Owner>Metafields, set<Owner>Metafield | @evershop/evershop/catalog/services |
customer | setCustomerMetafields, setCustomerMetafield | @evershop/evershop/customer/services |
order | setOrderMetafields, setOrderMetafield | @evershop/evershop/oms/services |
shop | setShopMetafields, setShopMetafield, getShopMetaData — no public export path (the base module has no service barrel in package.json exports). Use the PATCH /api/shop/metafields endpoint instead. | — |
blog_post, blog_category | None. Write values through the blog create/update API payload. | — |
Values are validated against the owner's definitions before they are written: unknown keys are dropped, required fields are enforced, and each value is checked against its compiled schema.
Reading values with GraphQL
Every metafield-capable type exposes two storefront-visible fields — metafields(namespace) and metafield(namespace, key) — returning the shared Metafield type:
query {
product(id: "…") {
metafields(namespace: "loyalty") {
namespace
key
type
value
}
metafield(namespace: "loyalty", key: "points") {
value
}
}
}
type Metafield {
namespace: String!
key: String!
type: MetafieldType!
value: JSON
}
Shop metafields are read from the setting root, which is available on any page:
query {
setting {
metafields(namespace: "custom") {
key
value
}
}
}
A few things to know about the read path:
- Audience gating. Fields whose definition has
visibleToCustomer: falseare dropped from customer-facing responses. On the storefront a request always resolves as thecustomeraudience, so admin-only fields never leak. - Every defined field appears. A field with no stored value comes back with
value: nullrather than being absent — the list is driven by the definitions, not by what happens to be stored. - Raw values are admin-only. The unfiltered
meta_dataobject is exposed asmetaData: JSONon the admin schema only; the storefront sees the shaped, audience-gatedmetafieldsfields.
Request-scoped caching
Shaping metafields loads the owner's definitions from the database. To keep listing pages efficient — a category page rendering metafields on 48 product cards would otherwise issue 48 identical definition queries — resolution is memoized per request. A definition cache is created once per GraphQL request and shared across every resolver, so each owner type's definitions are loaded once per request regardless of how many entities render. The cache lives and dies with the request, so an admin editing a definition sees the change on the very next page load — there is no stale cross-request cache to invalidate.
Theme-provisioned definitions
A definition carries an optional provisioned_by_theme attribution column. Themes declare the metafields they depend on, and the provisioner seeds them at install/boot, stamping the theme name onto each row it creates. Rows created by hand (admin UI, migration, createMetafieldDefinition) leave the column NULL. A definition that already exists and is unowned can be claimed by the first theme that seeds it — first seeder wins.
The base module seeds one such definition itself: shop / custom / copyright (modules/base/migration/Version-1.0.6.ts), a short_text field that drives the storefront footer copyright line. The themeConfig GraphQL resolver overlays its value onto themeConfig.copyRight, so the merchant-edited value wins and the config value is only the fallback.
Deleting a definition
Deleting a definition (DELETE /api/metafield-definitions/:uuid) removes the row and emits a metafield_definition_deleted event. Each owning module has a subscriber that prunes the deleted key from every row of its table's meta_data, so no orphaned values are left behind. Because this runs through the event system, cleanup is asynchronous — the values are already invisible to GraphQL (shaping is definition-driven) and are physically removed shortly after.
409 theme-provenance guard. If the definition's provisioned_by_theme names the currently active theme — or any theme present in theme_install_state — the delete is refused with a 409. Deleting it would fire the store-wide prune and drop every stored value, and the theme would just re-seed the definition empty at the next boot.
Pass ?force=true to override:
curl -X DELETE "https://mystore.com/api/metafield-definitions/<uuid>?force=true" \
-H "Authorization: Bearer $TOKEN"
The guard self-disables on databases that predate the attribution column, and never applies to definitions with provisioned_by_theme unset.
See also
- Registry and Processors — the fold processors that move
metafieldspayloads intometa_data - Events and Subscribers — the mechanism behind value pruning
- GraphQL — how the schema is assembled per module
- Database — the typed query builder and JSONB columns
Support us
EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.