Sidekick & AI

Scriban in XM and SXA: Null-Safe Templates for Headless Delivery

Workshop with stakeholders
Photo: Lucas / Unsplash · Royalty-free

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 item
  • o_pagemode: experience editor mode flags
  • sc_i18n / dictionary: localized strings
  • sc_parameter or 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 ~}}
Programming code on screen
Null-safe Scriban prevents CD exceptions when datasources are missing. Photo: Luis Gomes / Unsplash. Reference: Sitecore Documentation, Scriban templates.

o_pagemode and experience editor behavior

o_pagemode drives editor chrome vs live output:

  • o_pagemode.is_experience_editor: EE active
  • o_pagemode.is_preview: preview mode
  • o_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 RenderingContext or Scriban sc_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”.
Tablet and laptop with development tools
Run empty, partial, and full fixtures before each release candidate. Photo: Danial Iglesias / Unsplash. Reference: Sitecore Documentation, Layout Service.

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-main static root placeholder
  • corp-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_item access 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.