Scriban templates fail in production for predictable reasons: null datasource items, experience editor page modes authors never test, and field keys that differ between Layout Service JSON and GraphQL responses. Safe dynamic placeholders in XM mean every partial handles empty, partial, and full item states without throwing, and every environment (SXA MVC, headless JSS, Layout Service consumers) reads the same field API names from sc_item.
Scriban context objects in XM
Sitecore injects Scriban contexts depending on host:
sc_item: current rendering context item (often datasource)sc_page: context page itemo_pagemode: experience editor mode flagssc_i18n/ dictionary: localized stringssc_parameteror rendering parameters map
Know which item your rendering binds. Hero renderings usually bind datasource; route-level metadata binds page item. Mixing them causes “field empty in preview but filled on CD” bugs.
Null checks and safe field access
Never assume datasource exists. Authors delete datasources, break references, or publish pages before datasources.
{{~ if sc_item ~}} {{~ title = sc_item['Title'] ~}} {{~ if title && title != '' ~}} <h1>{{ title }}</h1> {{~ else if o_pagemode.is_experience_editor ~}} <div class="ee-placeholder">Hero: set Title on datasource</div> {{~ end ~}}
{{~ else if o_pagemode.is_experience_editor ~}} <div class="ee-placeholder">Hero: assign a datasource</div>
{{~ end ~}}
For nested objects (General Link, Image), guard each property:
{{~ link = sc_item['CTA Link'] ~}}
{{~ if link && link.url ~}} <a href="{{ link.url }}" {{ if link.target }}target="{{ link.target }}"{{ end }}> {{ sc_item['CTA Label'] | default: 'Learn more' }} </a>
{{~ end ~}}
Image fields return media items or null:
{{~ img = sc_item['Image'] ~}}
{{~ if img && img.media_url ~}} <img src="{{ img.media_url }}" alt="{{ img.alt | default: sc_item['Title'] }}" loading="lazy" />
{{~ end ~}}
o_pagemode and experience editor behavior
o_pagemode drives editor chrome vs live output:
o_pagemode.is_experience_editor: EE activeo_pagemode.is_preview: preview modeo_pagemode.is_normal: normal site view
Show placeholders only in EE, never on CD:
{{~ if o_pagemode.is_experience_editor && !sc_item ~}} <div class="ee-chrome" data-fieldhint="Assign Hero datasource"> [Hero placeholder] </div>
{{~ end ~}}
Do not render draft-only debug JSON in normal mode. Authors accidentally publish debug blocks when copy-pasting templates from Stack Overflow examples.
sc_item fields and type coercion
Field access by API name is case-sensitive in Scriban bindings:
{{ sc_item['MetaDescription'] }} {{# correct API name #}}
{{ sc_item['Meta Description'] }} {{# wrong: returns empty #}}
Checkbox fields return bool; droplists return string keys. Multilist returns arrays:
{{~ tags = sc_item['Tags'] ~}}
{{~ if tags && tags.size > 0 ~}} <ul class="tags"> {{~ for t in tags ~}} <li>{{ t }}</li> {{~ end ~}} </ul>
{{~ end ~}}
Rich text fields may include inline markup. Do not double-encode unless your design system requires it. Prefer a Scriban filter for allowed tags if authors paste from Word.
Partials and template composition
Extract repeated blocks into partials under your rendering views folder:
{{~ include 'partials/cta-button.html' ~}}
Partial partials/cta-button.html:
{{~ link = sc_item['CTA Link'] ~}}
{{~ label = sc_item['CTA Label'] | default: i18n['Default.CtaLabel'] ~}}
{{~ if link && link.url && label ~}} <a class="btn btn-primary" href="{{ link.url }}">{{ label }}</a>
{{~ end ~}}
Pass explicit context when partials serve multiple components:
{{~ include 'partials/media-image.html' fieldName: 'Image' fallbackAlt: sc_item['Title'] ~}}
Dictionary and i18n
Hard-coded English in Scriban blocks localization. Use dictionary entries:
{{~ i18n['Hero.DefaultCta'] | default: 'Learn more' ~}}
Dictionary keys live under /sitecore/system/Dictionary/. SXA projects often use @Translate helpers; in Scriban prefer explicit dictionary fetch wired in your rendering model if keys are missing on CD.
Pattern for fallback chain:
{{~ label = sc_item['CTA Label'] ~}}
{{~ if !label || label == '' ~}} {{~ label = i18n['Hero.DefaultCta'] ~}}
{{~ end ~}}
SXA vs MVC hosting differences
SXA renderings use Scriban with SXA-specific extension objects and theme paths. MVC with Sitecore.Mvc.Extensions may use different view engine wiring. Rules stay the same; paths differ.
- SXA: views under
/Views/Variants/, JSON variants for headless-style exports - MVC: views under
/Views/Corp/, controller renderings set model explicitly - Both: datasource resolution via
RenderingContextor Scribansc_item
When migrating MVC to SXA, diff Scriban for hard-coded paths like /sitecore/media library/.... Use @MediaManager or media item URLs from field objects instead.
Layout Service field parity
Headless consumers read the same fields via Layout Service JSON. Field keys in JSON typically match API names. Scriban on CM is your first test; Layout Service is the contract for JSS/Next.js.
Verify parity with a field manifest per template:
Template: Hero Layout Service keys: Title, Subtitle, Body, CTA Label, CTA Link, Image Scriban access: sc_item['Title'], ... (same API names) GraphQL alias: heroTitle @map(name: "Title") {{# only if alias required #}}
Layout Service response fragment:
{ "componentName": "Hero", "fields": { "Title": { "value": "Example" }, "Subtitle": { "value": "Supporting line" }, "Image": { "value": { "src": "/-/media/...", "alt": "" } } }
}
Scriban should not rename fields differently unless your JavaScript mapping layer documents the transform.
GraphQL field keys
GraphQL exposes fields with camelCase aliases depending on schema configuration. A Hero field MetaDescription may appear as metaDescription { value }.
query Hero($datasource: String!, $language: String!) { item(path: $datasource, language: $language) { Title: field(name: "Title") { value } metaDescription: field(name: "MetaDescription") { value } ctaLink: field(name: "CTA Link") { ... on LinkField { url target } } }
}
Spaces in API names (e.g. CTA Link) trip up teams that assume camelCase everywhere. Maintain a crosswalk table: API name, GraphQL alias, Scriban key, TypeScript prop.
Test matrix: empty, partial, full items
Every rendering template gets three fixture items in /sitecore/content/Tests/Scriban/:
| Fixture | State | Expected behavior |
|---|---|---|
| Hero-Empty | Datasource assigned, all fields blank | EE placeholder; CD renders nothing or skeleton |
| Hero-Partial | Title only, no image or CTA | Title renders; no broken img tag; no empty anchor |
| Hero-Full | All fields populated | Full markup; lazy load on image; accessible alt |
Automate HTML snapshot tests on CM if you have a test use; minimum manual QA each sprint.
Additional edge cases:
- Datasource null (reference broken)
- Image field points to unpublished media
- General Link internal vs external URL
- Rich text with script tags stripped
- Fallback language when EN empty, FR populated
C# rendering controller example (MVC bridge)
When Scriban sits inside a controller rendering, ensure model and Scriban context align:
public class HeroController : Controller
{ public ActionResult Index() { var item = RenderingContext.Current.Rendering.Item; if (item == null) { return View("~/Views/Corp/Hero.cshtml", new HeroViewModel()); } var vm = new HeroViewModel { Title = item["Title"], Subtitle = item["Subtitle"], Body = new HtmlString(item["Body"]), CtaLink = LinkManager.GetGeneralLinkField(item, "CTA Link"), Image = MediaManager.GetMediaUrl(item, "Image") }; return View("~/Views/Corp/Hero.cshtml", vm); }
}
Hybrid teams use C# for complex media URL logic and Scriban for markup. Do not duplicate field reads in both layers without tests.
Anti-patterns that break CD
- Uncached item.GetChildren() in Scriban loops: kills performance; precompute in controller or use query APIs sparingly.
- Throwing on null: one missing FAQ datasource takes down the whole page.
- Hard-coded production hostnames in links: breaks blue-green and preview.
- Field display names in brackets: works in dev if display matches API, fails after template rename.
- Mixing page and datasource fields without comment: next developer maps SEO to wrong item.
- EE-only content leaking via missing o_pagemode guard: customers see “Add datasource here”.
Dynamic placeholders
Dynamic placeholders (e.g. corp-main-{*}) require Scriban to render child placeholder output without assuming fixed component order:
<main> {{ sc_placeholder 'corp-main' }}
</main>
When using nested dynamic placeholders, document allowed renderings per placeholder in Sitecore rules. Scriban cannot fix invalid placeholder keys authors drag components into.
Performance notes
Scriban renders synchronously on CM during page assembly. Avoid N+1 item lookups inside tight loops. Batch related items in a single query in C# and pass arrays into Scriban when building nav or FAQ lists from child items.
Release checklist integration
Add Scriban fixture URLs to your smoke test list. Hit EE and normal mode URLs after deploy. Compare Layout Service JSON for the same page ID against Scriban HTML field presence.
Scriban filters for media and links
Register custom Scriban functions in Sitecore startup for repeated media patterns:
public static string MediaUrl(Item item, string fieldName)
{ if (item == null) return string.Empty; var imageField = (ImageField)item.Fields[fieldName]; if (imageField?.MediaItem == null) return string.Empty; return MediaManager.GetMediaUrl(imageField.MediaItem);
}
Template usage:
<img src="{{ item_media_url sc_item 'Image' }}" alt="{{ sc_item['Title'] }}" />
Centralizing URL logic prevents CM hostname leaks when authors copy Scriban between environments.
Language and fallback in Scriban
When sc_item language version is empty, fallback may come from embedded fields on a language-neutral item. Explicit pattern:
{{~ title = sc_item['Title'] ~}}
{{~ if !title || title == '' ~}} {{~ title = sc_item.GetFieldValue('Title', 'en') ~}}
{{~ end ~}}
Document fallback policy per template. SEO fields often should not fallback silently; hreflang pages need explicit 404 or empty, not English bleed-through.
SXA JSON variants for headless export
SXA Scriban JSON variants output component props for JavaScript consumers. Keep keys identical to Layout Service:
{ "title": "{{ sc_item['Title'] | escape }}", "subtitle": "{{ sc_item['Subtitle'] | escape }}"
}
Invalid JSON from unescaped quotes in Rich Text is a common production defect. Use escape filter or move JSON building to C# serializer.
Experience Editor chrome styling
EE placeholders should use consistent CSS classes documented in your style guide:
.ee-placeholder { border: 2px dashed #ccc; padding: 1rem; color: #666; font-size: 0.875rem;
}
Authors recognize dashed borders faster than blank white space. Do not use red error styling for optional empty fields.
Unit testing Scriban without CM
Extract Scriban templates to disk and test with Scriban CLI or embedded engine in xUnit:
[Fact]
public void Hero_empty_datasource_renders_no_h1()
{ var template = File.ReadAllText("Hero.scriban"); var result = Template.Parse(template).Render(new { sc_item = (Item)null, o_pagemode = new { is_experience_editor = false } }); Assert.DoesNotContain("<h1", result);
}
Mock sc_item with dictionary-backed fake if full Sitecore kernel is too heavy for CI.
Placeholder key naming conventions
Align Sitecore placeholder keys with frontend:
corp-mainstatic root placeholdercorp-main-{uid}dynamic nested sections- Document allowed renderings per key in Sitecore rules engine or SXA allowed controls
Scriban renders {{ sc_placeholder 'corp-main' }} but cannot fix invalid nesting authors create by drag-and-drop.
Caching and Scriban output
Html cache varies by placeholder when personalization is absent. Mark renderings cacheable only when datasource fields are publish-stable. Non-cacheable when Scriban reads query string or user context. Wrong cache config shows EE placeholders on CD briefly after deploy; purge cache on Scriban template publish.
GraphQL to Scriban parity checklist row
For each field on template, one spreadsheet row:
- API name
- Scriban access pattern
- Layout Service JSON path
- GraphQL field(name:) query fragment
- TypeScript interface property
- Empty behavior (omit, placeholder, default string)
Review spreadsheet in PR when adding fields. Faster than debugging blank Next.js components in UAT.
Anti-pattern: business logic in Scriban
Discount calculations, date formatting across timezones, and permission checks belong in C# or API layers. Scriban may format a precomputed date string, not compute fiscal quarter boundaries. Every line of logic in Scriban is untested unless you invest in CLI tests.
Debugging Scriban in Experience Editor
When markup is wrong in EE but looks fine in raw template file, check which item is bound to rendering. Use EE field diagnostics or temporary comment output guarded by o_pagemode.is_experience_editor showing item ID and template name. Remove before merge to main; never commit debug output without guard.
Common fix: datasource was assigned to wrong language version; Scriban reads empty FR fields while author edits EN datasource duplicate.
Actionable checklist
- Guard every
sc_itemaccess with null checks and EE placeholders. - Use field API names exclusively; maintain crosswalk for GraphQL aliases.
- Extract shared markup into partials with explicit parameters.
- Replace hard-coded strings with dictionary keys and fallbacks.
- Build Empty, Partial, and Full test items per rendering template.
- Verify Layout Service JSON keys match Scriban field access.
- Document SXA vs MVC view paths and datasource binding per rendering.
- Block debug output outside
o_pagemode.is_experience_editor. - Test unpublished media, broken datasource, and language fallback cases.
- Add Scriban fixture pages to post-deploy smoke tests.