Skip to main content

Building a Design System

A practical guide to structuring email templates that scale


Before You Start

Do you build one design system with multiple brands inside, or separate design systems for each brand?

This decision shapes everything. Get it wrong and you'll be rebuilding in six months. Answer these questions:

Do your brands use the same email layouts?

One system — Use a brand selector in the template base to swap colors, logos, and copy. Update once, all brands benefit.

Separate systems — Each brand evolves independently. More duplication upfront, but no risk of "one size fits none".


1. Template Base: Your Foundation

Think of the template base as the skeleton of every email you'll ever send. It wraps around all your modules and controls the things that should stay consistent: header, footer, and global settings.

The golden rule: If it appears on every email, it belongs in the template base. If it can be inserted multiple times or swapped out, it's a module.

Group Your Settings Logically

In Better Email, settings are organised into setting groups. Each group appears as a collapsible section in the email editor sidebar — so the structure you define here is exactly what your users see when they build an email.

A well-grouped template base might look like this in the editor:

Group nameWhat the user can configure
GeneralBackground color, global toggles
SenderSender type, language selection
HeaderLogo, show/hide toggle
FooterBackground color, show/hide toggle

The rule of thumb: Each group should answer one question — "What am I configuring here?" If a user has to scroll through image settings to find a text field, the grouping is wrong.

Keep group names short and in plain language. "Image Settings" beats "img_config". Your marketers are the ones reading these labels, not developers.


2. The Sender + Language Pattern

Here's where it gets powerful. Instead of making users manually enter addresses, unsubscribe links, and CTA text for every email, you let them pick two things:

Sender TypeLanguage
Who is sending?What language?
NewslettersEnglish
SalesDanish
Internal CommunicationGerman

Why This Matters

Consistency. The unsubscribe link for "Sales" emails always points to the sales unsubscribe page. No copy-paste errors. No broken links because someone typed the wrong URL.

Speed. Two dropdowns instead of five text fields. Your team builds emails faster and makes fewer mistakes.

Scalability. Need to add a fourth sender type? Update it once in the template base. Every email using that template gets it automatically.

What gets controlled by this pattern:

  • Unsubscribe text (Afmeld / Unsubscribe / Abmelden)
  • Unsubscribe URL (different per sender)
  • Contact CTA text and URL
  • Sender address

3. Global Control Over Modules

Here's a trick that separates good design systems from great ones: let the template base influence module behavior.

The Background Color Example

Your template base has a "General" setting with a background color. Modules can read this value using template.general.background_color.

This means:

  • ✅ Change the global background once → all modules update
  • ✅ Modules still have their own background override if needed
  • ✅ Brand consistency without micromanagement

Other Global Controls to Consider

  • Primary brand color — Used for buttons, links, accents
  • Font family — Keep typography consistent
  • Border radius — Rounded or sharp corners everywhere
  • Spacing scale — Consistent padding values

Code Examples

Let's look at the actual Liquid code that makes this system work.

Sender & Language Assignment

At the top of your template base, assign values based on the user's selection:

{% comment %} Get user selections {% endcomment %}
{% assign sender_type = template.sender.type %}
{% assign sender_lang = template.sender.language %}

{% comment %} Set text based on language {% endcomment %}
{% case sender_lang %}
{% when 'da' %}
{% assign unsubscribe_text = 'Afmeld nyhedsbrev' %}
{% assign cta_text = 'Kontakt os' %}
{% when 'en' %}
{% assign unsubscribe_text = 'Unsubscribe' %}
{% assign cta_text = 'Get in touch' %}
{% when 'de' %}
{% assign unsubscribe_text = 'Abmelden' %}
{% assign cta_text = 'Kontaktieren Sie uns' %}
{% endcase %}

{% comment %} Set URLs based on sender type {% endcomment %}
{% case sender_type %}
{% when 'newsletters' %}
{% assign unsubscribe_url = 'https://example.com/unsubscribe/newsletters' %}
{% when 'sales' %}
{% assign unsubscribe_url = 'https://example.com/unsubscribe/sales' %}
{% when 'internal' %}
{% assign unsubscribe_url = '#' %}
{% endcase %}

Then use the variables anywhere in your template:

<a href="{{ unsubscribe_url }}">{{ unsubscribe_text }}</a>

Result: Danish + Newsletters = "Afmeld nyhedsbrev" linking to /unsubscribe/newsletters

Rendering Images

Use the imgTag filter to output optimized images:

{% comment %} Basic image {% endcomment %}
{{ img.image | imgTag }}

{% comment %} With options {% endcomment %}
{{ img.image | imgTag: width: 560, dpr: 2 }}

{% comment %} Custom height from setting {% endcomment %}
{{ img.image | imgTag: width: 560, height: img.image_height, dpr: 2 }}

imgTag parameters:

ParameterDescription
widthOutput width in pixels
heightOutput height in pixels
dprDevice pixel ratio (use 2 for retina)
altTextAlt attribute for accessibility
styleInline CSS
classCSS class names

If the image input has built-in alt text enabled, imgTag will use the stored altText automatically. Only pass altText: when you want to override that value in the template code.

If you need just the URL (not the full tag), use resizeImage. Apply it to image.originalUrl rather than image.urlimage.url is already a resized URL with your settings applied, so using resizeImage on it is redundant:

{% comment %} For background images or custom markup {% endcomment %}
<td background="{{ image_set.image.originalUrl | resizeImage: width: 640 }}">

Looping Through Repeatable Settings

For repeatable settings (like article cards), use a for loop:

{% for item in articles %}
<table>
<tr>
<td>
{{ item.image | imgTag: width: 280, dpr: 2 }}
</td>
<td>
<p>{{ item.category }}</p>
<h3>{{ item.title }}</h3>
<p>{{ item.description }}</p>
<a href="{{ item.url }}">{{ item.link_text }}</a>
</td>
</tr>
</table>
{% endfor %}

Useful loop variables:

VariableDescription
forloop.indexCurrent iteration (1, 2, 3...)
forloop.index0Zero-based index (0, 1, 2...)
forloop.firstTrue on first item
forloop.lastTrue on last item

Example — alternate layout for even/odd items:

{% for item in articles %}
{% assign is_even = forloop.index | modulo: 2 %}

{% if is_even == 0 %}
{% comment %} Image on right {% endcomment %}
<td>{{ item.title }}</td>
<td>{{ item.image | imgTag }}</td>
{% else %}
{% comment %} Image on left {% endcomment %}
<td>{{ item.image | imgTag }}</td>
<td>{{ item.title }}</td>
{% endif %}
{% endfor %}

Referencing Template Base Settings from Modules

Modules can read template-level settings using the template. prefix:

{% comment %} In a module, use global background color {% endcomment %}
<table style="background-color: {{ template.general.background_color }};">

{% comment %} Or check the sender language {% endcomment %}
{% if template.sender.language == 'de' %}
<p>Deutscher Text hier</p>
{% endif %}

{% comment %} Variables assigned in template base are also available {% endcomment %}
<a href="{{ cta_url }}">{{ cta_text }}</a>

Key difference:

  • template.general.background_color — Template-level setting
  • main.background_color — Module-level setting
  • cta_text — Variable assigned in template base (no prefix needed)

4. Quick Wins

The 30-Second Checklist — Before you ship your design system, ask:

  • Are settings grouped by purpose, not by type?
  • Can a new user build an email without reading docs?
  • Are repeated values (colors, URLs) controlled centrally?
  • Does changing one setting break anything unexpected?
  • Is the naming in the user's language, not developer-speak?

Remember: The best design system is one your team actually uses. Keep it simple, keep it logical, and don't over-engineer.


5. Real Examples: Our Module Library

Theory is nice. Let's look at actual modules we've built and how their settings are organized.

Article Module

A hero-style module with image, headline, body text, and CTA button.

Setting groupKeyInputs
Main Settingsmainbackground_color — Color picker
Image Settingsimgimage, image_gravity (center/top/bottom/left/right), image_height, image_url, discover_overlay
Texttextheading_type (H1–H4), headline, body, button_text, button_url

Why this works: A user editing text never has to scroll past image settings. Everything is where you'd expect it.

CTA Banner Module

A bold call-to-action block with colored background.

Setting groupKeyInputs
Main Setupctabackground_color — Brand purple, black, white, or gray
Text & Buttontext_settingsheading_type (H1–H4), headline, body, button_text, button_url

Why this works: Simple module, simple structure. Only 2 groups because that's all it needs.

Divider Module

A spacing/separator element with optional line.

Setting groupKeyInputs
Background Settingsdividerbackground_color — Match surrounding sections
Line Settingslineshow_line (toggle), line_color (gray/light gray/brand purple), spacing (Small 10px / Normal 20px / Large 40px)

Why this works: Even a simple divider benefits from grouping. "I want to change the line" → go to Line settings. Done.

Article Card Module (Repeatable)

A card grid for articles/blog posts. Users can add multiple cards.

Setting groupKeyInputs
Section Setupsectionsection_title, heading_type (H1–H4), background_color
Articles (Repeatable)articlesimage, image_height, image_position, category, title, description, link_text, url, discover_overlay

Important: For repeatable settings, all inputs for one "item" must stay in the same setting group. You can't split image and text into separate repeatable groups — they'd lose their connection. The loop iterates over articles and each item has all its properties together.

Background Image Module

Full-width background image with text overlay.

Setting groupKeyInputs
Backgroundbackgroundbackground_color (fallback color), discover_overlay
Image Selectionimage_setimage, image_height
Text Contenttext_setheading_type, text_align, text_color, headline, body, button_text, button_url, button_color

Why this works: 12 inputs would be overwhelming in one list. Split into 3 focused groups, it's manageable. Users go straight to what they need.

The Pattern

Notice how every module follows similar grouping logic:

GroupPurpose
Main / SetupBackground color, general toggles
ImageImage upload, dimensions, alt text, positioning
TextHeadlines, body copy, alignment, colors
Button / CTASometimes merged with Text, sometimes separate

Consistency across modules = faster learning curve for users.


6. Accessibility: The EAA and Semantic Headings

Starting June 2025, the European Accessibility Act (EAA) requires digital products — including emails — to be accessible to people with disabilities. This isn't just compliance. It's good design.

What the EAA means for email:

  • Screen readers must be able to navigate content
  • Heading hierarchy must make logical sense
  • Color contrast must be sufficient
  • Interactive elements need clear labels

Why We Have Heading Type Selectors

You've seen heading_type in almost every module. Here's why:

❌ Bad: Visual-only thinking✅ Good: Semantic structure
"This text is big, so it's important""This is an H1, this is an H2 under it"
Screen readers can't see "big" — they need semantic meaningScreen readers announce heading levels and users can jump between them

The Heading Hierarchy Rule

Headings must follow a logical order. You can't jump from H1 to H4.

✅ H1: "Summer Sale"
✅ H2: "Women's Collection"
✅ H3: "Dresses"
✅ H3: "Shoes"
✅ H2: "Men's Collection"

✗ H1: "Summer Sale"
✗ H4: "Women's Collection" ← Skipped H2 and H3!

How This Works in Practice

In our design system, the user building an email chooses the heading level for each module:

Email structureHeading level
Hero / Main headlineH1 — One per email
Article section titlesH2 — Major sections
Card titles within sectionsH3 — Subsections
CTA banner at the bottomH2 — Another major section

By giving users control over heading levels, they can maintain proper hierarchy regardless of which modules they use or in what order.

Pro tip: Add a hint to the heading_type input explaining the hierarchy rule. Something like: "Use H1 for the main headline, H2 for section titles, H3 for subsections. Don't skip levels."

Other Accessibility Wins Built Into This System

  • Built-in image alt text — Enable Alt Text on image inputs instead of adding a separate image_alt field
  • Semantic HTML — We use actual <h1>, <h2> tags, not styled divs
  • Color options with contrast — Preset colors are chosen to meet WCAG contrast ratios
  • Link text inputs — "Read more" links have editable text, not just icons

Accessibility isn't an add-on. It's baked into the system from day one.


7. Set Good Defaults

A module should look great the moment it is dragged into the editor — no configuration required. If a user has to set colors, adjust spacing, and fill in placeholder text before the module looks usable, you have lost them.

Input defaults

Every input should have a default value that represents the most common use case. For a background color, that is probably white. For a button label, something like "Read more". For a heading type, H2. Think about how the module will be used in a real email and set defaults accordingly.

Bad defaults force every user to make the same decisions every time. Good defaults make the right choice automatically, and let users override only when they need something different.

Module spacing

One of the most important — and most overlooked — defaults is the spacing between modules. When a user drags in three modules in a row, they should sit naturally next to each other with consistent padding. If every module has its own internal padding set to zero, the email will look cramped. If they each add their own large top/bottom margin, sections will feel disconnected.

The standard approach is to define a consistent vertical spacing value — typically a top or bottom padding on each module — and use it as the default across all modules. A common pattern:

  • Default top padding: 40px
  • Default bottom padding: 40px

Expose a spacing setting (Small / Normal / Large) so users can tighten or open up specific modules when needed — but make sure the default works without any adjustment.

Why this matters beyond UX

Good defaults are not just about making life easier for the email editor user. They are also essential for Betty, the AI assistant that builds emails from prompts.

When Betty constructs an email, it selects and configures modules based on the instructions it receives. If a module requires manual setup before it looks correct, Betty cannot produce a finished result — it can only produce a starting point. Modules with solid defaults let Betty assemble emails that look great out of the box, without requiring a human to fix spacing, swap colors, or fill in placeholder content.

Think of your defaults as the AI's blank canvas. The better the canvas, the better the painting.


8. AI Instructions on Templates and Modules

Better Email lets you attach natural language instructions directly to your template base and to individual modules. These instructions are read by Betty when it is generating or editing emails, and they give you direct control over how the AI reasons about your design system.

Template-level instructions

Instructions on the template base give Betty context about the overall email system — what it is for, how it is structured, and any rules that apply globally.

Examples of useful template-level instructions:

  • "This template is used for B2B newsletters. Tone should be professional and informative."
  • "Always include an unsubscribe link in the footer. Never remove it."
  • "The primary brand color is #245157. Use it for buttons and accents."
  • "This template supports Danish, English, and German. Match the language to the sender type."

Module-level instructions

Instructions on individual modules tell Betty what the module is for, how it should be used, and any constraints to respect.

Examples of useful module-level instructions:

  • "Use this module for the main hero section. There should only be one per email."
  • "This is an article card module. Use it to present editorial content with an image, headline, and short description."
  • "The CTA button text should be action-oriented. Avoid generic labels like 'Click here'."
  • "This divider module is used to create visual separation between sections. Do not use it more than twice per email."

What makes a good instruction

Be specific about intent, not just appearance. Betty already knows what the module looks like — what it needs to know is when to use it, how many times, and what content fits.

Keep instructions short and directive. One to three sentences per module is usually enough. Long instructions with many caveats tend to produce inconsistent results.

If your module has a setting that Betty should almost always set to a specific value, say so explicitly: "Set the background color to white unless the brief specifically calls for a dark section."

The payoff

A design system with good AI instructions behaves like a trained collaborator. Betty understands the structure, respects the rules, and produces emails that fit the brand — not just technically valid emails that happen to use the right components. The time you invest in writing clear instructions pays back every time a marketer asks Betty to build an email from scratch.