A practical guide to writing CEL expressions in the CMS.
A practical guide to writing CEL expressions in the CMS.
CEL (Common Expression Language) is a lightweight scripting language built into our CMS. It lets you write dynamic expressions that can pull data from documents, read URL parameters, and compute values on the fly.
Here's what happens when a CEL script runs:
Your Script The Engine Result
| | |
v v v
documents.get("article", "intro") --> Fetches from database --> { headline: "Welcome", body: "..." }
.headline --> Extracts the field --> "Welcome"
Think of CEL as a read-only query language. It can't modify anything in the database - it just reads data and returns a computed result. This makes it safe to use anywhere in the CMS.
Every CEL expression has access to three things:
| Object | What It Is | Example |
|---|---|---|
documents | Fetch any document from the CMS | documents.get("country", "us") |
meta | Info about the current request (locale, URL params) | meta.locale, meta.params.slug |
schema | The current document's field definitions | schema.fields |
docWhen writing CEL expressions within a document editor, you can access the current document's field values using the doc object. This enables computed fields and cross-field references.
// Access current document's price field
doc.price
// Calculate total from current document fields
doc.price * doc.quantity
// Conditional based on current document status
doc.status == "published" ? doc.title : "Draft: " + doc.title
The doc object contains all field values from the document being edited. This is useful for:
doc.price * doc.quantity)The most powerful feature of CEL is fetching documents from anywhere in your CMS.
Syntax: documents.get(schemaName, identifier)
Let's say you have an article document stored with the identifier "welcome-post":
// Stored in the CMS as: article / welcome-post
{
"headline": "Welcome to Our Platform",
"author": "Sarah Chen",
"body": "We're excited to announce...",
"tags": ["announcement", "news"]
}
To fetch the whole document:
documents.get("article", "welcome-post")
Returns:
{
"headline": "Welcome to Our Platform",
"author": "Sarah Chen",
"body": "We're excited to announce...",
"tags": ["announcement", "news"]
}
To fetch just the headline:
documents.get("article", "welcome-post").headline
Returns: "Welcome to Our Platform"
To fetch the author:
documents.get("article", "welcome-post").author
Returns: "Sarah Chen"
When your page has dynamic routes (like /articles/[slug]), you can use meta.params to get the URL parameter and fetch the right document.
If someone visits /articles/welcome-post:
documents.get("article", meta.params.slug).headline
Returns: "Welcome to Our Platform"
This is how you build dynamic pages - the same CEL script works for any article, just using whatever slug is in the URL.
Syntax: documents.find(schemaName) or documents.find(schemaName, filter)
// Get all countries
documents.find("country")
Returns:
[
{ "code": "us", "name": "United States", "flag": "US" },
{ "code": "sa", "name": "Saudi Arabia", "flag": "SA" },
{ "code": "gb", "name": "United Kingdom", "flag": "GB" }
// Get countries with a filter
documents.find("country", { "where": { "code": "us" } })
Returns:
[
{ "code": "us", "name": "United States", "flag": "US" }
]
CEL supports fetching translated document content in two ways: automatic locale-based translation and explicit translation lookup.
When meta.locale is set (e.g., from route parameters or user preferences), documents.get() automatically merges translated content:
// If meta.locale is "fr", returns French translation merged with base document
documents.get("greeting", "welcome").headline
How it works:
meta.locale is not "en" or "en-US", looks up translation in the translations table{ ...baseContent, ...translatedContent }This means translated fields override base fields, while untranslated fields fall back to the base document.
For cases where you need to fetch a specific translation regardless of the current locale:
Syntax: documents.translated(schemaName, identifier, locale)
// Always fetch Spanish translation
documents.translated("greeting", "welcome", "es").headline
// Fetch translation based on URL parameter
documents.translated("product", meta.params.id, meta.params.lang).description
// Compare translations
documents.translated("article", "intro", "en").title + " / " + documents.translated("article", "intro", "fr").title
Your greeting documents with translations:
// Base document: greeting / welcome
{ "headline": "Welcome", "subheadline": "Welcome to our platform" }
// Translation (language: "fr")
{ "headline": "Welcome", "subheadline": "Welcome to our platform" }
// Translation (language: "es")
{ "headline": "Welcome", "subheadline": "Welcome to our platform" }
CEL scripts:
// With meta.locale = "fr"
documents.get("greeting", "welcome").headline
// Returns: "Welcome"
// Explicit Spanish translation
documents.translated("greeting", "welcome", "es").headline
// Returns: "Welcome"
// Fallback pattern for missing translations
documents.translated("greeting", "welcome", meta.params.lang) != null
? documents.translated("greeting", "welcome", meta.params.lang).headline
: documents.get("greeting", "welcome").headline
You have a hero-block that should display a headline pulled from an article document.
Your article document (identifier: "homepage-hero"):
{
"headline": "Build Faster, Ship Smarter",
"subheadline": "The modern CMS for developers"
}
CEL script in the hero block's title field:
documents.get("article", "homepage-hero").headline
Result: The hero displays "Build Faster, Ship Smarter"
You're building a page at /countries/[code] and want to display the full country name.
Your country documents:
// country / us
{ "code": "us", "name": "United States", "flag": "US", "languages": ["en", "es"] }
// country / sa
{ "code": "sa", "name": "Saudi Arabia", "flag": "SA", "languages": ["ar", "en"
CEL script:
documents.get("country", meta.params.code).name
When someone visits /countries/us:
meta.params.code = "us""United States"When someone visits /countries/sa:
meta.params.code = "sa""Saudi Arabia"Show different headlines based on the user's locale.
meta.locale == "ar-SA" ? "Welcome, everyone" : "Welcome"
If locale is "ar-SA": Returns "Welcome, everyone"
If locale is anything else: Returns "Welcome"
Your article has a countryCode field, and you want to get the full country name.
Article document:
{ "headline": "News from the US", "countryCode": "us" }
CEL script:
documents.get("country", documents.get("article", "us-news").countryCode).name
What happens:
documents.get("article", "us-news") returns { "headline": "News from the US", "countryCode": "us" }.countryCode extracts "us"documents.get("country", "us") returns { "code": "us", "name": "United States", ... }.name extracts "United States"Result: "United States"
If a document might not exist, you can provide a fallback:
documents.get("article", meta.params.slug) != null
? documents.get("article", meta.params.slug).headline
: "Article Not Found"
Or check if a specific field exists:
documents.get("article", "intro").author != null
? documents.get("article", "intro").author
: "Unknown Author"
Your article has tags, and you want to check if a specific tag exists:
"featured" in documents.get("article", "welcome-post").tags
Returns: true if the article has the "featured" tag
Get the first tag:
documents.get("article", "welcome-post").tags[0]
Returns: "announcement" (the first tag)
Count the tags:
size(documents.get("article", "welcome-post").tags)
Returns: 2 (number of tags)
Parametric routes are the key to building dynamic, localized pages. When you define a route pattern like /{lang}/landingPage, the CMS extracts parameters from the URL and makes them available via meta.params.
Route Pattern Definition:
Routes use :paramName or {paramName} syntax to define dynamic segments:
| Pattern | Example URL | Extracted Params |
|---|---|---|
/:lang/landingPage | /ko/landingPage | { lang: "ko" } |
/{country}/{lang}/products | /us/en/products | { country: "us", lang: "en" } |
/articles/:slug | /articles/welcome-post | { slug: "welcome-post" } |
Parameter Bindings: Each route parameter can be bound to a document schema for validation:
{
"pattern": "/{lang}/landingPage",
"param_bindings": {
"lang": "language"
}
}
This binding tells the CMS:
lang segment from the URLlanguage schema (looks for document where content.code matches)Route configuration:
/{lang}/landingPage/{lang}/landingPage{ "lang": "language" }Your greeting documents:
// greeting / ko
{ "code": "ko", "headline": "Welcome", "subheadline": "Welcome to our platform", "ctaText": "Get Started", "ctaUrl": "/ko/get-started" }
// greeting / en
{ "code": "en", "headline": "Welcome", "subheadline":
CEL script to fetch localized content:
documents.get("greeting", meta.params.lang).headline
How it resolves:
| URL | meta.params.lang | Result |
|---|---|---|
/ko/landingPage | "ko" | "Welcome" |
/en/landingPage | "en" | "Welcome" |
/ja/landingPage | "ja" | "Welcome" |
For routes like /{country}/{lang}/products:
Route configuration:
{
"pattern": "/{country}/{lang}/products",
"param_bindings": {
"country": "country",
"lang": "language"
}
}
CEL scripts:
// Get country name
documents.get("country", meta.params.country).name
// Get localized product list based on country
documents.find("product", { "where": { "country": meta.params.country } })
// Combined: Show country-specific greeting in user's language
documents.get("greeting", meta.params.lang).headline + " from " + documents.get("country", meta.params.country).name
Validation cascade:
The CMS validates parameters hierarchically. For /{country}/{lang} routes:
country param against country schemalang param against language schemalang is in country.languages[] array (hierarchical validation)meta.segments provides the raw URL path as an array, useful when you need positional access without named parameters.
How it works:
| URL Path | meta.segments |
|---|---|
/articles/tech/ai-news | ["articles", "tech", "ai-news"] |
/ko/landingPage | ["ko", "landingPage"] |
/us/en/products/featured | ["us", "en", "products", "featured"] |
/ | [] |
| Use Case | Best Approach |
|---|---|
| Named parameters from route pattern | meta.params.lang |
| Position-based access | meta.segments[0] |
| Getting path depth | size(meta.segments) |
| Checking if path contains segment | "admin" in meta.segments |
// Get first segment (often language code)
meta.segments[0]
// Check path depth
size(meta.segments) > 2 ? "deep" : "shallow"
// Check if we're in admin section
"admin" in meta.segments ? "admin mode" : "public mode"
// Fallback: Use segment if param not bound
has(meta.params.lang) ? meta.params.lang : meta.segments[0]
The meta object contains all context about the current request:
| Property | Type | Description | |
|---|---|---|---|
meta.locale | string | Current locale code (e.g., "en-US", "ko-KR", "ar-SA") | |
meta.params | Record<string, string> | Route parameters extracted from URL pattern | |
meta.segments | string[] | URL path split into segments | |
meta.docId | `string \ | null` | Current document UUID (null for new documents) |
meta.title | string | Current document title |
The locale code follows BCP 47 format (language-region):
// Check locale for RTL languages
meta.locale == "ar-SA" || meta.locale == "he-IL" ? "rtl" : "ltr"
// Get language part only
meta.locale.split("-")[0] // Not supported - use meta.params.lang instead
Route parameters are always strings. The CMS validates them against bound schemas before evaluation:
// Access named parameter
meta.params.lang // "ko"
meta.params.country // "us"
meta.params.slug // "welcome-post"
// Check if parameter exists
has(meta.params.category) // true/false
// Use in document fetch
documents.get("greeting", meta.params.lang)
documents.ref("airports").get(meta.params.code)
Raw URL segments as an array:
// Access by index (0-based)
meta.segments[0] // First segment
meta.segments[1] // Second segment
// Check length
size(meta.segments) // Number of segments
// Check membership
"products" in meta.segments // Does path include "products"?
The current document's UUID, useful for self-referential scripts:
// Only available when editing existing documents
meta.docId != null ? "editing" : "creating new"
// Use in conditional logic
meta.docId != null ? documents.get("article", meta.docId).status : "draft"
The current document's title:
// Use for display
"Editing: " + meta.title
// Conditional based on title
meta.title.contains("Draft") ? "work in progress" : "published"
For cleaner syntax when the schema is known but identifier is dynamic:
// Traditional approach
documents.get("airports", meta.params.code).name
// Using ref() - schema separate from dynamic identifier
documents.ref("airports").get(meta.params.code).name
Both are equivalent, but ref() makes the dynamic part clearer.
documents.get("schema", "identifier") // Get one document
documents.get("schema", "id").fieldName // Get a specific field
documents.find("schema") // Get all documents
documents.find("schema", { "where": {...}}) // Filtered query
documents.ref("schema").get(identifier) // Chained lookup
documents.translated("schema", "id", "fr") // Get with explicit locale
meta.locale // "en-US", "ar-SA", etc.
meta.params.xyz // URL parameter named "xyz"
meta.segments // URL path as array: ["articles", "intro"]
meta.segments[0] // First path segment
meta.docId // Current document ID (or null)
meta.title // Current document title
doc.fieldName // Current document's field value (in editor context)
// Comparison
== != < <= > >=
// Logic
&& || !
// Ternary (if-else)
condition ? valueIfTrue : valueIfFalse
// Membership
"value" in listOrMap
size(list) // Count items
size(string) // String length
"text".startsWith("te") // true
"text".endsWith("xt") // true
"text".contains("ex") // true
has(object.property) // Check if property exists
hasProperty(obj, "key") // Check if object has key (alternative syntax)
If something goes wrong, you'll see one of these:
| Error | What It Means |
|---|---|
SYNTAX_ERROR | Typo in your script (missing quote, bad operator) |
TYPE_ERROR | You're mixing types that don't work together |
RUNTIME_ERROR | The script ran but hit a problem (undefined variable) |
FETCH_LIMIT_EXCEEDED | You're fetching too many documents (max 50) |
TIMEOUT | Script took too long (max 5 seconds) |
AST_DEPTH_EXCEEDED | Expression too deeply nested (max depth: 50) |
SCRIPT_TOO_LONG | Script exceeds 5000 character limit |
The CEL engine is designed for extensibility. Future planned capabilities include:
// Future: Call external services via MCP
mcp.translate(meta.params.text, "en", meta.params.lang)
mcp.analyze(documents.get("article", meta.params.id).body)
// Future: AI-powered content generation
ai.summarize(documents.get("article", meta.params.id).body, 100)
ai.translate(meta.params.text, meta.params.targetLang)
ai.classify(meta.params.input, ["positive", "negative", "neutral"])
These capabilities will be added through the registered function system, maintaining backward compatibility with existing scripts.
documents. or meta. and the editor will show available optionsdocuments.get("schema", "id") first, then add .fieldName!= null ? ... : ...documents.get() or documents.find() counts against the 50-fetch limithas(meta.params.category) before accessingThis section covers advanced patterns for linking documents together and building relational content structures.
The simplest form: one document references another by identifier.
// Article stores author ID, fetch author's name
documents.get("author", documents.get("article", "intro").authorId).name
For cleaner syntax when the identifier is dynamic:
// Traditional approach
documents.get("country", documents.get("airport", meta.params.code).countryCode).name
// Using ref() - clearer when schema is known but identifier is dynamic
documents.ref("country").get(documents.get("airport", meta.params.code).countryCode).name
Build deep relationships by chaining multiple lookups:
// Airport → Country → Region → Continent
documents.get("continent",
documents.get("region",
documents.get("country",
documents.get("airport", meta.params.code).countryCode
).regionCode
).continentCode
).name
Combine document references with translations:
// Get localized country name for an airport
documents.translated("country",
documents.get("airport", meta.params.code).countryCode,
meta.params.lang
).name
Document stores an ID referencing another document.
// article / tech-news
{ "title": "Tech Update", "authorId": "author-123", "categoryId": "cat-tech" }
// Resolve author name
documents.get("author", documents.get("article", meta.params.slug).authorId).name
// Resolve category with fallback
documents.get("article", meta.params.slug).categoryId != null
? documents.get("category", documents.get("article", meta.params.slug).categoryId).name
: "Uncategorized"
Documents reference each other by semantic codes rather than UUIDs.
// airport / JFK
{ "code": "JFK", "name": "John F. Kennedy International", "countryCode": "us" }
// country / us
{ "code": "us", "name": "United States", "currencyCode": "usd" }
// currency / usd
{ "code": "usd", "symbol":
// Airport → Country → Currency chain
documents.get("currency",
documents.get("country",
documents.get("airport", meta.params.code).countryCode
).currencyCode
).symbol
// For JFK: Returns "$"
Use doc for computed fields that reference other documents based on the current document's values.
// In a product document, fetch related category details
documents.get("category", doc.categoryId).description
// Computed shipping cost based on product's origin country
documents.get("shipping-rates", doc.originCountry).baseRate * doc.weight
When documents reference each other, be careful of fetch limits.
// Get article's author, then get author's other articles (watch fetch count!)
documents.find("article", { "where": { "authorId": documents.get("article", meta.params.slug).authorId } })
When a field can reference different schemas:
// content-block / hero-1
{ "type": "hero", "sourceType": "article", "sourceId": "welcome-post" }
// content-block / hero-2
{ "type": "hero", "sourceType": "product", "sourceId": "featured-item" }
// Dynamic schema lookup based on sourceType
documents.get("content-block", "hero-1").sourceType == "article"
? documents.get("article", documents.get("content-block", "hero-1").sourceId).headline
: documents.get("product", documents.get("content-block", "hero-1").sourceId).name
Every documents.get(), documents.find(), and documents.ref().get() call is tracked for cache invalidation. When a referenced document changes, the CMS knows which CEL expressions need re-evaluation.
Tracked dependencies include:
schema:identifier - Specific document dependencyschema:identifier - Same as get, via chained syntaxschema:* - Schema-level dependency (any document in schema)// Bad: Fetches same document twice
documents.get("author", documents.get("article", "intro").authorId).name + " - " +
documents.get("author", documents.get("article", "intro").authorId).bio
// Better: Use conditional to check once
documents.get("article", "intro").authorId != null
? documents.get("author", documents.get("article", "intro").authorId).name
: "Unknown Author"
This walkthrough creates a multi-language landing page accessible at /{lang}/landingPage.
In the CMS admin, create a custom schema called greeting:
{
"name": "greeting",
"fields": [
{ "name": "code", "type": "string", "required": true },
{ "name": "headline", "type": "string", "required": true },
{ "name": "subheadline"
Create documents for each language:
Document: greeting / ko
{
"code": "ko",
"headline": "Welcome",
"subheadline": "Welcome to our platform",
"ctaText": "Get Started",
"ctaUrl": "/ko/get-started"
}
Document: greeting / en
{
"code": "en",
"headline": "Welcome",
"subheadline": "Welcome to our platform",
"ctaText": "Get Started",
"ctaUrl": "/en/get-started"
}
Document: greeting / ja
{
"code": "ja",
"headline": "Welcome",
"subheadline": "Welcome to our platform",
"ctaText": "Start",
"ctaUrl": "/ja/get-started"
}
Create a route with the following configuration:
/{lang}/landingPage/{lang}/landingPage {
"lang": "language"
}
Add a hero block to the route with these CEL scripts for each field:
Headline field:
documents.get("greeting", meta.params.lang).headline
Subheadline field:
documents.get("greeting", meta.params.lang).subheadline
CTA Text field:
documents.get("greeting", meta.params.lang).ctaText
CTA URL field:
documents.get("greeting", meta.params.lang).ctaUrl
Create a catch-all route in your Next.js app:
// app/[...slug]/page.tsx
import { getCmsClient } from '@repo/renderer';
interface PageProps {
params: { slug: string[] };
}
export default async function Page({ params }: PageProps) {
Visit these URLs to see localized content:
| URL | Expected Headline |
|---|---|
/ko/landingPage | Welcome |
/en/landingPage | Welcome |
/ja/landingPage | Welcome |
When a user visits /ko/landingPage:
/{lang}/landingPage patternmeta.params.lang = "ko"language schemadocuments.get("greeting", meta.params.lang) resolve to Korean contentinterface CelMeta {
/** Current locale code (e.g., 'en-US') */
locale: string;
/** Route parameters extracted from URL */
params: Record<string, string>;
/** URL path segments */
segments: string[];
/** Current document ID (if editing existing document) */
The extractParams function processes URL paths:
Pattern: /{country}/{lang}/products
Path: /us/en/products
Algorithm:
1. Normalize both (remove trailing slashes)
2. Split into segments: ["us", "en", "products"] and ["{country}", "{lang}", "products"]
// Simple binding (uses "code" field for lookup)
{ "lang": "language" }
// Detailed binding (custom slug field)
{
"lang": {
"schemaName": "language",
"slugField": "code"
},
"slug": {
"schemaName": "article",
"slugField": "slug"
}
When fetching via documents.get(schema, identifier):
idcontent.code fieldcontent.slug fieldtitle fieldThis allows flexible document references by any unique identifier.