CREATE PAGE - MDL Syntax Guide
SkillDev toolsLets your agent check CREATE PAGE syntax, variables, and widget properties before it tries to create claude skill pages.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the CREATE PAGE - MDL Syntax Guide skill
About this capability
CREATE PAGE syntax reference, parameters, variables, layouts, and the full widget vocabulary. Use before writing any CREATE PAGE statement, or when a widget's property spelling needs checking.
What this skill tells your AI
The instructions your AI receives, as published by mendixlabs/mxcli in .claude/skills/mendix/create-page/SKILL.md and read by ahel’s review.
Reference files
SKILL.md covers page structure, the syntax, and what does not work. The bulk is
next door:
reference/widgets.md— the widget catalogue: every supported widget with its properties and the exact MDL spelling. Look a widget up here before writing it; guessing a property name is the most common way a page fails to build.reference/examples.md— complete pages, end to end, to copy and adapt rather than assemble from parts.
For the widgets this project actually has — including any marketplace or custom
ones — read the generated widgets skill instead
(.ai-context/skills/widgets/SKILL.md).
Overview
Guide for writing CREATE PAGE statements in Mendix Definition Language (MDL).
Syntax
create [or replace] page Module.PageName
(
[params: { $ParamName: Module.EntityType | PrimitiveType, ... },]
[variables: { $varName: DataType = 'defaultExpression', ... },]
title: 'Page Title',
layout: Module.LayoutName,
[url: 'page-url',]
[folder: 'FolderPath',]
[PopupWidth: 800, PopupHeight: 480, PopupResizable: true,]
[Class: 'css-class', Style: 'css: rule']
)
{
-- Widget definitions using explicit properties
}
Pop-up dimensions (PopupWidth / PopupHeight / PopupResizable) apply when the
page is opened in a pop-up. They are optional — omitting them uses the Mendix defaults
(600 × 600, not resizable). Unlike the other header keywords, these property names are
case-sensitive and must be written exactly as shown. They can also be changed later
with alter page … { set PopupWidth = …; } (see the alter-page skill).
Page CSS class / style (Class / Style) set the page's Appearance — a CSS class
and inline style applied to the whole page (e.g. Class: 'container-fluid bg-light').
Both are optional and can be changed later with alter page … { set Class = '…'; }.
Page Variables: Local variables at the page level for use in expressions (e.g., column visibility).
- DataType:
boolean,string,integer,decimal,datetime - Default value: Mendix expression in single quotes
- Referenced in expressions as
$varName - Use for DataGrid2 column
visible:(which hides/shows entire column, NOT per-row)
Key Syntax Elements
| Element | Syntax | Example |
|---|---|---|
| Properties | (key: value, ...) | (title: 'Edit', layout: Atlas_Core.Atlas_Default) |
| Widget name | Required after type | textbox txtName (...) |
| Attribute binding | attribute: AttrName | textbox txt (label: 'Name', attribute: Name) |
| Attribute over an association | attribute: Assoc/Attr (bare association name, multi-hop OK) | textbox txt (label: 'Rule', attribute: RuleAction_BusinessRule/Name) |
| Password field | Password: true | textbox tbPw (attribute: Secret, Password: true) |
| Widget validation | Validation: '<expr>' + ValidationMessage: '<text>' | Validation: 'length(toString($value)) > 0' — quoted, not [bracketed] |
| Variable binding | datasource: $Var | dataview dv (datasource: $Product) { ... } |
| Action binding | action: type | actionbutton btn (caption: 'Save', action: save_changes) |
| Database source | datasource: database entity | datagrid dg (datasource: database Module.Entity) |
| Selection binding | datasource: selection widget | dataview dv (datasource: selection galleryList) |
| CSS class | class: 'classes' | container c (class: 'card mx-spacing-top-large') |
| Inline style | style: 'css' | container c (style: 'padding: 16px;') |
| Design properties | designproperties: [...] | container c (designproperties: ['Spacing top': 'Large', 'full width': on]) |
FOLDER Option
Place pages in folders for better organization:
create page MyModule.CustomerEdit
(
title: 'Edit Customer',
layout: Atlas_Core.PopupLayout,
folder: 'Customers'
)
{
-- widgets
}
-- Nested folders (created automatically if they don't exist)
create page MyModule.OrderDetail
(
title: 'Order Details',
layout: Atlas_Core.Atlas_Default,
folder: 'Orders/Details'
)
{
-- widgets
}
Styling: Class, Style, and DesignProperties
Three styling mechanisms can be applied to any widget:
CSS Class — Atlas UI utility classes or custom CSS classes:
container c (class: 'card mx-spacing-top-large') { ... }
actionbutton btn (caption: 'Save', class: 'btn-lg')
Inline Style — One-off CSS styles (use sparingly):
container c (style: 'background-color: #f8f9fa; padding: 16px;') { ... }
Warning: Do NOT use
styledirectly on DYNAMICTEXT widgets — it crashes MxBuild with a NullReferenceException. Wrap the DYNAMICTEXT in a styled CONTAINER instead.
Design Properties — Atlas UI structured properties (spacing, colors, toggles):
-- Option property: 'Key': 'Value'
container c (designproperties: ['Spacing top': 'Large', 'Background color': 'Brand Primary']) { ... }
-- Toggle property: 'Key': ON (enabled) or OFF (disabled/omitted)
container c (designproperties: ['Full width': on]) { ... }
-- Multiple types combined
actionbutton btn (caption: 'Save', designproperties: ['Size': 'Large', 'Full width': on])
Dynamic Classes — a Mendix expression evaluated at runtime that returns a
class list (applied on top of the static class). Root attributes in
$currentObject and escape single quotes by doubling them (''):
dynamictext ovChip (
content: 'chip',
class: 'ss-chip',
dynamicclasses: 'if $currentObject/VesselClass = Mod.BoatClass.Astute then ''ss-chip--astute'' else '''''
)
All can be combined on a single widget:
container ctnHero (
class: 'card',
style: 'border-left: 4px solid #264AE5;',
dynamicclasses: 'if $currentObject/Featured then ''is-featured'' else ''''',
designproperties: ['Spacing top': 'Large', 'Full width': on]
) {
dynamictext txtTitle (content: 'Styled Container', rendermode: H3)
}
mxcli checkwarns (MDL-WIDGET07) when a built-in widget carries an unrecognized property (a typo, or a property mxcli doesn't persist) — it would otherwise be silently dropped on write. It is a warning, not an error, so the check still passes; fix the spelling or remove the property.
Basic Examples
Simple Page with Title
create page MyModule.HomePage
(
title: 'Home Page',
layout: Atlas_Core.Atlas_Default
)
{
dynamictext welcomeText (content: 'Welcome to My App', rendermode: H1)
}
Page with Multiple Widgets
create page MyModule.CustomerPage
(
title: 'Customer Details',
layout: Atlas_Core.Atlas_Default
)
{
layoutgrid mainGrid {
row row1 {
column col1 (desktopwidth: 12) {
dynamictext heading (content: 'Customer Information', rendermode: H2)
}
}
row row2 {
column col2a (desktopwidth: 6) {
actionbutton btnSave (caption: 'Save', action: save_changes, buttonstyle: primary)
}
column col2b (desktopwidth: 6) {
actionbutton btnCancel (caption: 'Cancel', action: cancel_changes)
}
}
}
}
Layout Placeholders (multiple content areas)
By default all top-level widgets bind to the layout's Main placeholder. When a
layout has more than one placeholder (e.g. Main + a sidebar/topbar), use a
placeholder <Name> { … } block to assign widgets to a specific placeholder. Bare
widgets still bind to Main.
-- Atlas_Core.Atlas_SideBar has two placeholders: Main and Topbar
create page MyModule.Dashboard (title: 'Dashboard', layout: Atlas_Core.Atlas_SideBar)
{
placeholder Main {
dynamictext lblMain (content: 'Main content area')
}
placeholder Topbar {
dynamictext lblTop (content: 'Top bar content')
}
}
Notes:
- The placeholder name must match a placeholder defined in the layout (e.g.
Main,Right,Topbar,Content— depends on the layout). An unknown name failsmx check. - Keyword-like names (
Right,Left,Content) are accepted. describe pageemitsplaceholderblocks for multi-placeholder pages so they round-trip.
Modifying Existing Pages
To make targeted changes to an existing page (change a label, add a field, remove a widget), use alter page instead of create or replace page. ALTER PAGE modifies the widget tree in-place, preserving properties that MDL doesn't model.
-- Change a button caption and add a field
alter page Module.Customer_Edit {
set caption = 'Save & Close' on btnSave;
insert after txtEmail {
textbox txtPhone (label: 'Phone', attribute: Phone)
}
};
See the dedicated skill file: ALTER PAGE/SNIPPET
Conditional Visibility and Editability
Any widget (including CONTAINER) can have conditional visibility. Input widgets can also have conditional editability. Use bracket syntax [expression]:
-- Conditionally visible widget (boolean attribute)
textbox txtName (label: 'Name', attribute: Name, visible: [IsActive])
-- Conditionally visible container
container ctnDetails (visible: [Name != '']) { dynamictext t (content: '...') }
-- Conditionally editable input (boolean)
textbox txtStatus (label: 'Status', attribute: status, editable: [CanEdit])
-- Enum comparison: use the QUALIFIED enum value. Attributes are rooted for you,
-- but a bare enum VALUE would be treated as an attribute — always qualify it.
textbox txtNotes (label: 'Notes', attribute: Notes,
visible: [Status = MES.EquipmentStatus.Running])
-- Combined
textbox txtEmail (label: 'Email', attribute: Email,
visible: [ShowEmail],
editable: [CanEdit])
-- Static values still work
textbox txtReadOnly (label: 'Read Only', attribute: Name, editable: Never)
textbox txtHidden (label: 'Hidden', attribute: Name, visible: false)
-- A quoted-string expression is also accepted (CREATE and ALTER). Unlike the
-- bracket form, it is NOT auto-rooted — write $currentObject/ yourself.
dynamictext ovChip (content: 'chip', visible: '$currentObject/Name != empty')
-- Function calls work in the bracket form, including functions whose name is
-- also an MDL keyword (trim, length, find). Arguments are rooted like any other
-- reference.
dynamictext tTrim (content: 'x', visible: [trim($currentObject/Slug) != ''])
textbox txtSlug (label: 'Slug', attribute: Slug, editable: [length(Slug) > 0])
visible:/editable:is a Mendix expression, not XPath — a different function set from a datasourcewhereclause, even though both use[ ... ]:
visible:/editable:(client expression)where [ … ](XPath)String tests trim(),length(),toUpperCase(),find(),contains()contains(),starts-with(),ends-with(),string-length()length()character count number of elements in a list Emptiness $currentObject/X != ''/!= empty[X = empty]or[X = NULL]— a keyword, neverempty(…)Aggregates not available count()/avg()/min()/max()/sum()are Java-API-onlymxcli's grammar accepts any function name in both and lets MxBuild adjudicate, so a wrong-context call surfaces as CE0117 "Error(s) in expression" at build rather than as a parse error. See the Mendix reference guide: XPath constraint functions, XPath keywords.
An unparseable conditional is an error, not a silent drop. If the expression inside
visible: [ ... ]/editable: [ ... ]can't be parsed, the property has nowhere to go and would vanish on write — leaving the widget unconditionally visible/editable, which looks identical to a specificity bug in the running app.mxcli checkreports this as MDL-WIDGET19 and fails the command instead. Until v0.16.x,trim(…)andlength(…)hit exactly this path and disappeared without a word (issue #852).
Attribute rooting is automatic — a bare attribute in a widget visibility/editability expression (
[Name != ''],[IsActive]) is rooted in the widget data context as$currentObject/Name != ''for you, so it no longer triggers CE0117. Paths you write with an explicit$currentObject/…or$Param/…prefix pass through unchanged.Enum comparison differs by context:
- Widget visibility/editability expression (per-object): qualified enum value —
[Status = MES.EquipmentStatus.Running](theStatusattribute is rooted for you; the value must stay qualified, or it would be mistaken for an attribute).- XPath datasource constraint (
where […]): the string key works —where [Status = 'Running'](see xpath-constraints).- Microflow expression: qualified value —
$obj/Status = MES.EquipmentStatus.Running(see write-microflows).Widget-level visibility does not apply to DataGrid2 column
visible:(next section), which hides/shows the whole column and must use page variables.
Known Limitations
The following features are NOT implemented in mxcli and require manual configuration in Studio Pro:
| Feature | Workaround |
|---|---|
| Nested dataviews filtering by parent | Use microflow datasource or configure in Studio Pro |
| Complex conditional visibility | Configure visibility rules in Studio Pro |
| Widget-level security | Configure access rules in Studio Pro |
Runtime Pitfalls
Empty CONTAINER crashes at runtime. A CONTAINER with no child widgets compiles and builds successfully but crashes when the page loads with "Did not expect an argument to be undefined". Always include at least one child widget:
-- Wrong: crashes at runtime CONTAINER spacer1 (Style: 'height: 6px;') -- Correct: include a child (even a space) CONTAINER spacer1 (Style: 'height: 6px;') { DYNAMICTEXT spacerText (Content: ' ', RenderMode: Paragraph) }
content: ''(empty string) fails MxBuild. An empty Content on DYNAMICTEXT causes a misleading error: "Place holder index 1 is greater than 0, the number of parameter(s)." Use a single space instead:-- Wrong: MxBuild error DYNAMICTEXT spacer (Content: '') -- Correct: use a space DYNAMICTEXT spacer (Content: ' ')
IMAGE needs a source
An image widget shows an entry from an image collection by default, and the
entry is named as three parts — Module.Collection.ImageName:
image imgLogo (Image: 'MyFirstModule.Images._1', Width: 48, Height: 48)
show image collections lists the collections; describe image collection MyFirstModule.Images lists the images inside one.
An image with that (default) source and no Image: builds into a model mxbuild
refuses — "No image selected." — so mxcli check reports it as MDL-WIDGET22
before you spend a build on it. A name that does not resolve is reported by
mxcli check --references, which is cheaper than mxbuild's CE1613 "The selected
image … no longer exists."
The two other sources take no collection entry:
image imgRemote (ImageType: imageUrl, ImageUrl: 'https://example.com/logo.svg')
image imgIcon (ImageType: icon)
A button's icon is one of three elements
Mendix stores three different icon elements, and the keyword picks which:
actionbutton btnEdit (Caption: 'Edit', Action: nothing, Icon: 'Atlas_Core.Atlas_Filled.pencil')
actionbutton btnLogo (Caption: 'Logo', Action: nothing, Icon: image MyFirstModule.Images.logo)
actionbutton btnHome (Caption: 'Home', Action: nothing, Icon: glyph 57377)
| form | element | holds |
|---|---|---|
| bare name | Forms$IconCollectionIcon | a name in an icon collection |
image <name> | Forms$ImageIcon | a name in an image collection |
glyph <code> | Forms$GlyphIcon | a font character code, no name |
The first two are spelled identically and point into different documents, so
the image keyword is the only thing separating them. Write an image reference
without it and mxcli stores a custom-icon reference, which fails the build with
CE1613 "The selected custom icon … no longer exists." mxcli check -p app.mpr --references resolves each kind against its own collection and names the remedy
when the kind is wrong, which is cheaper than a build.
Any icon collection works, third-party ones included — show icon collections
lists them and describe icon collection Atlas_Core.Atlas_Filled lists the names
(they are non-obvious: it is add, not plus).
A glyph has a code and no name. The codes are sparse, and an undefined one
fails only at mxbuild --target=deploy — with "An exception occurred while
exporting page ''", naming the page and never the icon — so mxcli check
reports it as MDL078 first. Browse them with show glyphs.
describe page emits all three forms, so describe → exec round-trips a button's
icon whichever kind it is.
Binding across modules and to audit members
An attribute path may cross module boundaries, including into the platform's
System module — the association does not need to live in the same module as
the entity it targets:
create association IT.Issue_Assignee from IT.Issue to System.User;
DATAVIEW dv (DataSource: $Issue) {
DYNAMICTEXT txtAssignee (Attribute: Issue_Assignee/Name) -- into System
DYNAMICTEXT txtApprover (Attribute: Issue_Approver/Name) -- into another module
}
A bare association name is qualified with the module of the entity the widget
sits on. On a ComboBox that matters: its DataSource: is the option list, but
A text box that holds a secret needs Password: true. It is not cosmetic: without
it the field renders the value in plaintext, and before ako/mxcli#550 a
describe page → exec round trip silently turned every stored password field
into an ordinary one — so copying a login or change-password page lost it.
An input widget can also traverse an association to show a value from the
other side: attribute: Assoc/Attr binds the far attribute and stores the hops,
which is what Studio Pro does. It works on textbox, textarea, datepicker,
dropdown, checkbox and radiobuttons, and on data grid columns, with the same
bare-association spelling in each. Note what it is NOT: this shows a value from
the associated object, it does not make it editable through the association —
for editing the other object, nest a dataview over the association instead.
Association: names a reference on the containing entity, so
Association: Issue_Assignee resolves against the dataview's entity, not the
option list's module.
Audit members declared with the Auto* pseudo-types bind under the name you
declared:
create or modify persistent entity IT.Issue ( CreatedDate: AutoCreatedDate );
DYNAMICTEXT txtCreated (Attribute: CreatedDate) -- also accepts createdDate
Script Execution Note: Script execution stops on the first error. If a page fails to create (e.g., invalid widget syntax), earlier statements in the script will have already been committed. Plan scripts with uncertain syntax in phases.
Tips
- OR REPLACE: Use to recreate existing pages
- Widget Names: Required - use descriptive camelCase names
- Layout Requirement: Layout must exist in the project
- Nesting: Use
{ }blocks for all widget children - Properties: Use
(key: value)syntax for all widget properties - Bindings: Use
attribute:for attributes,datasource:for data,action:for buttons
Related Commands
alter page Module.PageName { ... }- Modify page widgets in-place (SET, INSERT, DROP, REPLACE)alter snippet Module.SnippetName { ... }- Modify snippet widgets in-placedescribe page Module.PageName- View page source in MDL format (shows Class, Style, DesignProperties)describe snippet Module.SnippetName- View snippet source in MDL formatshow pages [in module]- List all pagesshow widgets [where ...] [in module]- Discover widgets across pages/snippetsupdate widgets set ... where ... [dry run]- Bulk update widget properties (see below)drop page Module.PageName- Delete a page
Bulk Widget Updates
Use update widgets to change properties across many widgets at once:
-- Preview changes first (always use DRY RUN)
update widgets set 'Class' = 'card' where widgettype like '%Container%' in MyModule dry run;
-- Apply changes
update widgets set 'showLabel' = false where widgettype like '%combobox%';
-- Multiple properties
update widgets set 'Class' = 'btn-lg', 'Style' = 'margin-top: 8px;' where widgettype like '%ActionButton%';
PLUGGABLEWIDGET Escape Hatch
All shorthand widgets (IMAGE, COMBOBOX, GALLERY, DATAGRID, etc.) are pluggable widgets under the hood. When the shorthand doesn't expose a property you need, use pluggablewidget 'widget.id' name (properties) for full access to all widget properties.
-- Shorthand (common properties only)
image imgLogo (Image: 'MyFirstModule.Images._1', width: 48, height: 48)
-- Full PLUGGABLEWIDGET syntax (all properties available)
pluggablewidget 'com.mendix.widget.web.image.Image' imgLogo (
datasource: imageUrl, imageUrl: 'img/logo.svg',
widthUnit: pixels, width: 48, heightUnit: pixels, height: 48
)
The project's own widgets are documented as a skill: read
.ai-context/skills/widgets/SKILL.md (also at .claude/skills/widgets/SKILL.md)
for the index, then the per-widget file for the one you are placing — it carries
the full property table with enumeration values, nested object properties, child
slots and object lists.
mxcli widget docs -p app.mpr regenerates it (so does refresh catalog), and
mxcli widget describe <name> -p app.mpr reads the same data live from the
.mpk when a widget has been upgraded since.
See Also
- Overview Pages - CRUD page patterns
- Master-Detail Pages - Selection binding pattern
Signals
- GitHub stars
- 122
- Forks
- 49
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
create-page- Source
- github.com/mendixlabs/mxcli