Site Builder Architecture

The SleekCMS site builder turns your content and templates into a static website — with no servers, databases, Git repositories, or deployment pipelines to manage. This page is the technical reference for how that process works: how templates are structured, how they compose into pages, what data and helpers are available during rendering, and how the final HTML output is produced.

Whether you're writing your first page template or designing a block library for a multi-page marketing site, this is the page that explains what's available to you and how the pieces connect.


How Templates Generate a Static Site

The site builder produces a complete static site by processing every page record through a pipeline of templates. Understanding this pipeline helps you see where each template type fits and what data flows through it.

The Build Pipeline

When you build your site, the builder iterates over every page record in your content and produces one HTML file per page. For each page, the rendering follows this sequence:

1. Page template — The builder takes the page record and renders it through the page model's EJS template. The page record's field data becomes the item variable. If the page has dynamic block fields, the page template calls render() to delegate each block to its own template. The result is the page's body HTML.

2. Block templates — When render() is called on a dynamic block field, the builder iterates over the block instances. For each block, it looks up the block model's template and renders it with the block's field data as item. The rendered HTML for all blocks is concatenated and returned to the page template.

3. Entry templates — If a page or block calls render() on a referenced entry, the builder renders the entry model's template with the entry's data as item. This is optional — entries without templates are accessed through their field data directly.

4. Layout template — After the page template finishes rendering, the builder wraps its output with the layout template. The page's rendered HTML becomes available as the main variable inside the layout. The layout adds the outer HTML shell — <!DOCTYPE html>, <head>, navigation, footer — around the page content.

For each page record:
┌──────────────────────────────────────────────────┐
│  Layout Template                                 │
│  ├── <html>, <head>, nav, footer                 │
│  └── <%- main %>                                 │
│       └── Page Template                          │
│           ├── Fixed fields (title, meta, hero)   │
│           └── <%- render(item.sections) %>       │
│                ├── hero.ejs       → item = {...} │
│                ├── features.ejs   → item = {...} │
│                └── cta.ejs        → item = {...} │
└──────────────────────────────────────────────────┘
                    ↓
            about/index.html

Global Content Availability

Every template in the pipeline — layout, page, block, entry — has access to the entire site's content, not just the current record. This means a block template can look up entries, a page template can query other pages for related content links, and the layout template can pull in navigation data from an admin-only entry. The current record is always available as item, while the full site content is accessible through helper methods like getEntry(), getPages(), and getContent().

Output

The builder produces one HTML file per page record. Static pages generate a single file at their defined path (/aboutabout/index.html). Page collections generate one file per record using the slug (/blog + hello-worldblog/hello-world/index.html). The complete set of HTML files, combined with your CSS, JavaScript, and other assets, forms a self-contained static site ready for deployment.

Site BuilderIntegrated JAMstack


How EJS Templates Work

SleekCMS templates use EJS (Embedded JavaScript) syntax. EJS was chosen because it gives you the full power of JavaScript directly inside your templates — not just variable interpolation and basic loops, but the entire language. You can use array methods like filter(), map(), and sort() to transform content, write conditional logic of any complexity, declare variables and helper functions, and work with dates, strings, and objects using standard JavaScript. There's no limited template DSL to learn — if you can write JavaScript, you can write EJS templates.

There are three tag types you'll use:

<%= expression %> — Outputs the value with HTML escaping. Use for text content where you want to prevent HTML injection.

<%- expression %> — Outputs the value without escaping. Use for trusted HTML output — rendered blocks, image elements, rich text fields, and helper functions that return HTML.

<% code %> — Executes JavaScript without outputting anything. Use for logic — loops, conditionals, variable assignments.

<!-- Escaped output (safe for user-provided text) -->
<h1><%= item.title %></h1>

<!-- Unescaped output (for HTML content and helper output) -->
<div class="content"><%- item.body %></div>
<%- render(item.sections) %>
<%- img(item.hero, '800x400') %>

<!-- Logic (no output) -->
<% if (item.featured) { %>
  <span class="badge">Featured</span>
<% } %>

<% const posts = getPages('/blog'); %>
<% posts.forEach(post => { %>
  <a href="<%- path(post) %>"><%= post.title %></a>
<% }); %>

The item Variable

When a template renders, the current record's data is available as item. This is the primary way you access content in any template.

In a page template, item is the page record. It contains all the fields defined in the page model, plus system properties:

  • item._path — The full path of the page (e.g., /about, /blog/hello-world)
  • item._slug — The slug portion of the path, present on collection pages (e.g., hello-world)
  • item._meta.updated_at — Timestamp of the last update
<!-- Page template -->
<article>
  <h1><%= item.title %></h1>
  <time datetime="<%= item.published_date %>"><%= item.published_date %></time>
  <img src="<%- src(item.image, '1200x600') %>" alt="<%= item.image.alt %>">
  <div class="content"><%- item.content %></div>
</article>

In a block template, item is the block instance. It contains the block's field data plus item._meta.name, which is the block model's handle.

<!-- Hero block template -->
<section class="hero" style="background-image: url('<%- src(item.background, '1920x800') %>')">
  <h2><%= item.heading %></h2>
  <p><%= item.subheading %></p>
  <a href="<%= item.cta_url %>" class="btn"><%= item.cta_label %></a>
</section>

In an entry template, item is the entry record with its field data and item._meta.updated_at.

<!-- Author entry template -->
<div class="author-card">
  <%- img(item.headshot, '80x80') %>
  <strong><%= item.name %></strong>
  <p><%= item.bio %></p>
</div>

Direct Content Access

In addition to item, every template has direct access to the full site content through these top-level variables:

pages

An array of all page records in the site. Each page object contains its fields and system properties (_path, _slug, _meta).

<% pages.forEach(page => { %>
  <a href="<%- page._path %>"><%= page.title %></a>
<% }); %>

entries

An object containing all entries, keyed by handle. Single entries are objects; entry collections are arrays of objects.

<footer>
  <p><%- entries.footer.copyright_text %></p>
</footer>

<% entries.authors.forEach(author => { %>
  <span><%= author.name %></span>
<% }); %>

images

An object containing all images that have a handle, keyed by that handle. Each image has url and alt properties.

<img src="<%- images['site-logo'].url %>" alt="<%- images['site-logo'].alt %>">

options

An object containing all option sets that have a handle, keyed by that handle. Each option set is an array of { label, value } pairs.

<% options.categories.forEach(cat => { %>
  <span class="tag" data-value="<%- cat.value %>"><%= cat.label %></span>
<% }); %>

Recommendation: Use the helper methods (getEntry(), getPage(), getImage(), getOptions()) instead of accessing these variables directly. The helpers provide a cleaner, more readable API and are consistent with the @sleekcms/client library, making it easier to switch between site builder templates and headless API usage.


Rendering Helpers

These helpers handle template rendering and HTML generation — the core tools for composing pages from blocks, rendering images, and building navigation.

main

The rendered output of the page model's template. This is only relevant inside the layout template, where it represents the page body content that the layout wraps.

<!-- Layout template -->
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
</head>
<body>
  <header><!-- navigation --></header>

  <%- main %>

  <footer><!-- footer content --></footer>
</body>
</html>

The layout template provides the HTML shell. main is where the page template's output gets inserted. You never use main inside page, block, or entry templates — only in the layout.

render(val, separator?)

Renders a block, an entry, or an array of blocks/entries using their associated templates. This is how composable layouts work — the page template delegates rendering to each block's own template.

Parameters:

  • val — A single block/entry object or an array of block/entry objects
  • separator (optional) — A string inserted between rendered items when val is an array (default: "")

Returns: The rendered HTML string.

<!-- Render all dynamic block sections on a page -->
<%- render(item.sections) %>

<!-- Render a single block field -->
<%- render(item.hero_block) %>

<!-- Render dynamic blocks with a separator between each -->
<%- render(item.sections, '<hr class="section-divider">') %>

<!-- Render an entry that has a template -->
<%- render(item.author) %>

When you call render(item.sections), the builder iterates over every block instance in the sections field, looks up each block model's EJS template, renders it with the block's field data as item, and concatenates the HTML output. The page template doesn't need to know what types of blocks are present or how to render them.

src(obj, attr)

Generates an optimized image URL with resizing parameters. Use this when you need just the URL — for background-image styles, custom <img> elements, or anywhere you need the raw URL string.

Parameters:

  • obj — An image object (e.g., item.image, item.hero)
  • attr — Dimensions as a string "WxH" or an object with { w, h, size, type, fit } properties

Returns: A URL string.

<!-- String dimensions -->
<img src="<%- src(item.hero, '800x600') %>" alt="<%= item.hero.alt %>">

<!-- Object dimensions with format and fit -->
<img src="<%- src(item.thumbnail, { w: 400, h: 300, fit: 'cover', type: 'webp' }) %>">

<!-- Size shorthand in object form -->
<img src="<%- src(item.photo, { size: '400x300', fit: 'cover' }) %>">

<!-- Background image -->
<div style="background-image: url('<%- src(item.banner, '1920x600') %>')"></div>

If the image object is invalid or missing, src() returns a placeholder URL (https://placehold.co/{size}.png), so your templates won't break from missing images.

img(obj, attr)

Generates a complete <img> HTML element. A convenience wrapper around src() that also handles the alt attribute and supports class and style properties.

Parameters:

  • obj — An image object
  • attr — Dimensions as a string "WxH" or an object with { w, h, size, type, fit, class, style } properties

Returns: An <img> HTML string.

<!-- Simple image element -->
<%- img(item.hero, '800x600') %>
<!-- Output: <img src="https://img.sleekcms.com/.../image?w=800&h=600" alt="Hero description"> -->

<!-- With class and style -->
<%- img(item.hero, { size: '800x600', class: 'hero-img', style: 'object-fit: cover' }) %>

<!-- Placeholder fallback when image is missing -->
<%- img(null, '400x300') %>
<!-- Output: <img src="https://placehold.co/400x300.png" alt=""> -->

Use img() when you want a simple image element. Use src() when you need the URL for a background image or a custom element structure.

picture(obj, attr)

Generates a <picture> element with support for dark/light theme variants. If the image has dark or light variant images assigned, picture() automatically includes <source> elements with prefers-color-scheme media queries.

Parameters:

  • obj — An image object with optional dark/light variant properties
  • attr — Dimensions as a string "WxH" or an object with { w, h, size, type, fit, class, style } properties

Returns: A <picture> HTML string.

<!-- Basic picture element (no theme variants) -->
<%- picture(item.hero, '800x600') %>
<!-- Output: <picture><img src="...?w=800&h=600" alt="..."></picture> -->

<!-- Image with dark mode variant -->
<%- picture(item.logo, '200x50') %>
<!-- Output:
<picture>
  <source srcset="...logo-dark?w=200&h=50" media="(prefers-color-scheme: dark)">
  <img src="...logo?w=200&h=50" alt="Logo">
</picture>
-->

<!-- Image with both dark and light variants -->
<%- picture(item.brand_logo, '300x100') %>
<!-- Output:
<picture>
  <source srcset="...logo-dark..." media="(prefers-color-scheme: dark)">
  <source srcset="...logo-light..." media="(prefers-color-scheme: light)">
  <img src="...logo..." alt="Brand Logo">
</picture>
-->

Use picture() for logos, brand assets, or any image that should adapt to the user's color scheme preference.

svg(obj, attr?)

Inlines an SVG image directly into the HTML with optional attributes applied to the root <svg> element. This gives you full control over SVG styling through CSS, unlike an <img> tag which treats the SVG as an opaque image. The SVG markup is sanitized before inlining to remove potentially malicious content such as embedded scripts, event handlers, and external references.

Parameters:

  • obj — An image object whose URL points to an SVG file
  • attr (optional) — An object of HTML attributes to apply to the <svg> element

Returns: The inline SVG markup.

<!-- Basic inline SVG -->
<%- svg(item.icon) %>

<!-- With custom attributes -->
<%- svg(item.logo, { width: 120, height: 40, class: 'logo' }) %>

path(obj)

Returns the URL path for a page object. This is a convenience wrapper that reads obj._path, returning '#' if the page is not found.

<a href="<%- path(item) %>">Current Page</a>

<% getPages('/blog').forEach(post => { %>
  <a href="<%- path(post) %>"><%= post.title %></a>
<% }); %>

Content Access Helpers

These methods provide a clean, readable API for querying content from any template. They mirror the methods available in @sleekcms/client, so the same mental model applies whether you're writing templates for the site builder or fetching content through the API.

getContent(search?)

Returns all site content, or filters it with a JMESPath query.

Parameters:

  • search (optional) — A JMESPath expression to filter content

Returns: The matched content, or the full content object { pages, entries, images, options }.

<!-- Get all content -->
<% const content = getContent(); %>

<!-- Get all pages -->
<% const allPages = getContent('pages'); %>

<!-- Get a specific entry -->
<% const footer = getContent('entries.footer'); %>

<!-- Filter pages with JMESPath -->
<% const aboutPage = getContent('pages[?_path==`/about`] | [0]'); %>
<% const featuredPosts = getContent('pages[?featured==`true`]'); %>

getPages(path, opts?)

Returns all pages whose path starts with the given prefix.

Parameters:

  • path — The path prefix to match
  • opts (optional) — Options object with { collection: boolean } to filter to collection pages only

Returns: An array of page objects.

<!-- Get all blog posts -->
<% const posts = getPages('/blog'); %>

<!-- Get only collection items (excludes the index page) -->
<% const blogPosts = getPages('/blog', { collection: true }); %>

<% posts.forEach(post => { %>
  <article>
    <h2><a href="<%- path(post) %>"><%= post.title %></a></h2>
    <p><%= post.published_date %></p>
  </article>
<% }); %>

getPage(path)

Returns a single page by exact path match.

Parameters:

  • path — The exact path to match

Returns: The page object, or undefined if not found.

<% const about = getPage('/about'); %>
<% if (about) { %>
  <a href="<%- path(about) %>"><%= about.title %></a>
<% } %>

<% const home = getPage('/'); %>

getEntry(handle)

Returns an entry by its handle. Single entries return an object; entry collections return an array of objects.

Parameters:

  • handle — The entry handle

Returns: An entry object, an array of entry objects, or undefined.

<!-- Single entry -->
<% const footer = getEntry('footer'); %>
<footer>
  <p><%- footer.copyright_text %></p>
  <nav>
    <% footer.socials.forEach(social => { %>
      <a href="<%- social.link %>"><i class="<%- social.icon %>"></i></a>
    <% }); %>
  </nav>
</footer>

<!-- Entry collection -->
<% const authors = getEntry('authors'); %>
<% authors.forEach(author => { %>
  <div class="author"><%= author.name %></div>
<% }); %>

getSlugs(path)

Returns an array of slug strings for pages under a given path. Useful for generating navigation or listing collection items.

Parameters:

  • path — The base path to extract slugs from

Returns: An array of strings.

<% const slugs = getSlugs('/blog'); %>
<!-- ['italian', 'hidden', 'tacos', 'simit'] -->

getImage(handle)

Returns an image by its handle.

Parameters:

  • handle — The image handle

Returns: An image object { url, alt, ... } or undefined.

<% const logo = getImage('site-logo'); %>
<% if (logo) { %>
  <img src="<%- src(logo, '200x50') %>" alt="<%= logo.alt %>">
<% } %>

getOptions(handle)

Returns an option set — an array of label/value pairs.

Parameters:

  • handle — The option set handle

Returns: An array of { label, value } objects, or undefined.

<% const categories = getOptions('blog-categories'); %>
<ul class="filter-list">
  <% categories?.forEach(cat => { %>
    <li data-value="<%- cat.value %>"><%= cat.label %></li>
  <% }); %>
</ul>

Head Injection Methods

These methods add elements to the page's <head> section and script area from within any template — page, block, entry, or layout. Duplicate entries are automatically prevented based on content hashing.

title(str)

Sets the page <title> element.

<% title(item.title + ' | My Website') %>
<!-- Output: <title>About Us | My Website</title> -->

meta(obj)

Adds a <meta> tag to the head.

<% meta({ name: 'author', content: 'John Doe' }) %>
<!-- Output: <meta name="author" content="John Doe"> -->

<% meta({ property: 'og:type', content: 'website' }) %>
<!-- Output: <meta property="og:type" content="website"> -->

<% meta({ name: 'description', content: item.meta_description }) %>

link(value, order?)

Adds a <link> tag. Accepts a string URL (auto-detects type by extension) or an object with explicit attributes. The optional order parameter controls output order — lower numbers appear first.

<!-- Stylesheet (auto-detected from .css extension) -->
<% link('/css/custom.css') %>
<!-- Output: <link rel="stylesheet" type="text/css" href="/css/custom.css"> -->

<!-- RSS feed (auto-detected from .xml extension) -->
<% link('/feed.xml') %>
<!-- Output: <link rel="alternate" type="application/rss+xml" href="/feed.xml"> -->

<!-- Favicon (auto-detected from .ico extension) -->
<% link('/favicon.ico') %>
<!-- Output: <link rel="icon" href="/favicon.ico"> -->

<!-- Canonical URL -->
<% link({ rel: 'canonical', href: 'https://example.com' + path(item) }) %>

<!-- Preconnect -->
<% link({ rel: 'preconnect', href: 'https://fonts.googleapis.com' }) %>

style(value, order?)

Adds inline CSS to the head as a <style> element.

<% style('.hero { background: #000; color: #fff; }') %>
<!-- Output: <style>.hero { background: #000; color: #fff; }</style> -->

script(value, order?)

Adds a script to the page (appended to <body>). Accepts a .js URL string for an external script, a code string for inline JavaScript, or an object with src/content and additional attributes.

<!-- External script -->
<% script('/js/analytics.js') %>
<!-- Output: <script src="/js/analytics.js"></script> -->

<!-- Inline JavaScript -->
<% script('console.log("Page loaded");') %>
<!-- Output: <script>console.log("Page loaded");</script> -->

<!-- Deferred external script -->
<% script({ src: '/js/app.js', defer: true }) %>
<!-- Output: <script src="/js/app.js" defer="true"></script> -->

<!-- ES module -->
<% script({ src: '/js/module.js', type: 'module' }) %>
<!-- Output: <script src="/js/module.js" type="module"></script> -->

Putting It All Together

These examples show how the template context pieces fit together in real templates.

Layout Template

The layout wraps every page with the site's HTML shell. It uses main to inject the page template output and head injection methods for metadata.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
  <header>
    <% const nav = getEntry('header'); %>
    <a href="/">
      <%- picture(getImage('site-logo'), '150x40') %>
    </a>
    <nav>
      <% getPages('/').forEach(page => { %>
        <a href="<%- path(page) %>"><%= page.title %></a>
      <% }); %>
    </nav>
  </header>

  <main>
    <%- main %>
  </main>

  <footer>
    <% const footer = getEntry('footer'); %>
    <p><%- footer.copyright_text %></p>
    <div class="socials">
      <% footer.socials.forEach(social => { %>
        <a href="<%- social.link %>"><i class="<%- social.icon %>"></i></a>
      <% }); %>
    </div>
  </footer>
</body>
</html>

Blog Post Page Template

A page template that combines fixed fields with dynamic block sections, SEO metadata, and related post navigation.

<% title(item.title + ' | My Blog') %>
<% meta({ name: 'description', content: item.seo?.description || '' }) %>
<% link({ rel: 'canonical', href: 'https://example.com' + path(item) }) %>

<article class="blog-post">
  <header>
    <h1><%= item.title %></h1>
    <time datetime="<%= item.published_date %>"><%= item.published_date %></time>

    <% if (item.category && item.category.length) { %>
      <div class="categories">
        <% item.category.forEach(cat => { %>
          <span class="tag"><%= cat %></span>
        <% }); %>
      </div>
    <% } %>
  </header>

  <% if (item.image) { %>
    <%- img(item.image, { size: '1200x600', class: 'featured-image' }) %>
  <% } %>

  <div class="content">
    <%- item.content %>
  </div>
</article>

<!-- Related posts -->
<aside class="related-posts">
  <h2>More Posts</h2>
  <div class="grid">
    <% getPages('/blog', { collection: true })
        .filter(p => p._path !== item._path)
        .slice(0, 3)
        .forEach(post => { %>
      <a href="<%- path(post) %>" class="card">
        <%- img(post.image, '400x250') %>
        <h3><%= post.title %></h3>
      </a>
    <% }); %>
  </div>
</aside>

Landing Page with Dynamic Blocks

A page model that combines a fixed hero area with composable block sections below.

<% title(item.title + ' | My Site') %>

<section class="hero">
  <h1><%= item.title %></h1>
  <p><%= item.tagline %></p>
</section>

<!-- Dynamic block sections — each renders with its own block template -->
<%- render(item.sections) %>

Each block in item.sections is rendered by its own template. For example, a "Features Grid" block template:

<!-- features-grid block template -->
<section class="features">
  <h2><%= item.heading %></h2>
  <div class="grid">
    <% item.items.forEach(feature => { %>
      <div class="feature-card">
        <%- img(feature.icon, '48x48') %>
        <h3><%= feature.title %></h3>
        <p><%= feature.description %></p>
      </div>
    <% }); %>
  </div>
</section>

And a "CTA Banner" block template:

<!-- cta block template -->
<section class="cta-banner">
  <h2><%= item.heading %></h2>
  <p><%= item.body_text %></p>
  <a href="<%= item.button_url %>" class="btn btn-primary"><%= item.button_label %></a>
</section>

Dropdown-Driven Layout Variants

Using option set values from dropdown fields to control rendering:

<!-- Block template with layout variant from a dropdown field -->
<section class="features-section layout-<%- item.layout %>">
  <% if (item.layout === 'grid') { %>
    <div class="grid grid-cols-3">
      <% item.features.forEach(f => { %>
        <div class="card"><%= f.title %></div>
      <% }); %>
    </div>
  <% } else if (item.layout === 'list') { %>
    <ul>
      <% item.features.forEach(f => { %>
        <li><%= f.title %> — <%= f.description %></li>
      <% }); %>
    </ul>
  <% } %>
</section>

Entry References in a Blog Post

When a page references an entry, the entry data is available inline:

<!-- Blog post with author reference -->
<article>
  <h1><%= item.title %></h1>
  <%- item.content %>

  <% if (item.author) { %>
    <div class="author-bio">
      <%- img(item.author.headshot, '64x64') %>
      <strong><%= item.author.name %></strong>
      <p><%= item.author.bio %></p>
    </div>
  <% } %>
</article>

If the author entry model has its own template, you can delegate rendering instead:

<% if (item.author) { %>
  <%- render(item.author) %>
<% } %>

Template Context Summary

Context Type Available In Description
item Object All templates Current page, block, or entry record
main String Layout only Rendered page template output
pages Array All templates All page records
entries Object All templates All entries keyed by handle
images Object All templates All handled images
options Object All templates All handled option sets
Helper Returns Purpose
render(val) HTML string Render block/entry through its template
src(obj, attr) URL string Generate optimized image URL
img(obj, attr) HTML string Generate <img> element
picture(obj, attr) HTML string Generate <picture> with theme variants
svg(obj, attr?) HTML string Inline SVG with attributes
path(obj) String Get URL path for a page
getContent(search?) Any Query all content with JMESPath
getPages(path, opts?) Array Find pages by path prefix
getPage(path) Object Find single page by exact path
getEntry(handle) Object/Array Get entry by handle
getSlugs(path) Array Extract slugs from collection pages
getImage(handle) Object Get a handled image
getOptions(handle) Array Get an option set
title(str) void Set page <title>
meta(obj) void Add <meta> tag
link(value, order?) void Add <link> tag
style(value, order?) void Add inline <style>
script(value, order?) void Add <script> to body

What's Next