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 name | What the user can configure |
|---|---|
| General | Background color, global toggles |
| Sender | Sender type, language selection |
| Header | Logo, show/hide toggle |
| Footer | Background 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 Type | Language |
|---|---|
| Who is sending? | What language? |
| Newsletters | English |
| Sales | Danish |
| Internal Communication | German |
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:
| Parameter | Description |
|---|---|
width | Output width in pixels |
height | Output height in pixels |
dpr | Device pixel ratio (use 2 for retina) |
altText | Alt attribute for accessibility |
style | Inline CSS |
class | CSS 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.url — image.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:
| Variable | Description |
|---|---|
forloop.index | Current iteration (1, 2, 3...) |
forloop.index0 | Zero-based index (0, 1, 2...) |
forloop.first | True on first item |
forloop.last | True 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 settingmain.background_color— Module-level settingcta_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 group | Key | Inputs |
|---|---|---|
| Main Settings | main | background_color — Color picker |
| Image Settings | img | image, image_gravity (center/top/bottom/left/right), image_height, image_url, discover_overlay |
| Text | text | heading_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 group | Key | Inputs |
|---|---|---|
| Main Setup | cta | background_color — Brand purple, black, white, or gray |
| Text & Button | text_settings | heading_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 group | Key | Inputs |
|---|---|---|
| Background Settings | divider | background_color — Match surrounding sections |
| Line Settings | line | show_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 group | Key | Inputs |
|---|---|---|
| Section Setup | section | section_title, heading_type (H1–H4), background_color |
| Articles (Repeatable) | articles | image, 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
articlesand each item has all its properties together.
Background Image Module
Full-width background image with text overlay.
| Setting group | Key | Inputs |
|---|---|---|
| Background | background | background_color (fallback color), discover_overlay |
| Image Selection | image_set | image, image_height |
| Text Content | text_set | heading_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:
| Group | Purpose |
|---|---|
| Main / Setup | Background color, general toggles |
| Image | Image upload, dimensions, alt text, positioning |
| Text | Headlines, body copy, alignment, colors |
| Button / CTA | Sometimes 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 meaning | Screen 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 structure | Heading level |
|---|---|
| Hero / Main headline | H1 — One per email |
| Article section titles | H2 — Major sections |
| Card titles within sections | H3 — Subsections |
| CTA banner at the bottom | H2 — 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_typeinput 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_altfield - 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.