Back to Blog

How We Built Structured Custom Templates: Letting Users Restyle Without Opening Up Arbitrary CSS

MD-TO Team

We hear the same request from users all the time: “The GitHub template looks great, but can I change the heading color to our brand color? And bump the line-height a bit?”

It sounds like a small feature, but there’s a tradeoff you can’t avoid: do you open up arbitrary CSS or not?

This post walks through the path we ended up taking — structured overrides on top of existing system templates — and why this fits a “one piece of content, many export formats” tool like md-to.com better than the alternatives.


Two extremes that don’t quite work

Most Markdown tools handle “custom styling” in one of two ways:

Option one: fixed templates only. Simple and stable, but not enough. Users pick the closest-looking template and then immediately wish they could change just one thing — the heading color.

Option two: arbitrary CSS. Maximum freedom, the preview area can be tweaked endlessly. The catch: looking good in the browser doesn’t mean looking good after export.

We build a tool that converts Markdown into PDF / Word / HTML / images, and several of those export paths don’t consume CSS at all:

  • Word (.docx) uses OOXML styles. There’s no concept of “arbitrary CSS” in there.
  • PDF printing has its own pagination and margin rules. Layouts tuned purely with browser CSS often fall apart at print time.
  • Image export goes through canvas screenshotting, and support for some CSS features (parts of filter, backdrop-filter) is limited.

If we let users write arbitrary CSS, the preview would look fine and exports would silently break. That “what you see is not what you get” gap is a terrible user experience.

So we took a third path.


Our approach: base template + structured overrides

The core idea in one sentence: users pick a system template first, then change a small set of structured fields on top of it.

We don’t store a full template — we store “base template ID + overrides”:

{
  version: 1,
  baseTemplateId: 'classic',
  overrides: {
    colors: {
      heading: '#0F766E'
    },
    fonts: {
      body: 'Noto Sans SC'
    },
    fontSizes: {
      body: 13
    },
    spacing: {
      lineHeight: 1.7
    }
  }
}

Why not store a full template snapshot? Three reasons:

  1. Users benefit when the base improves. If we later tighten the code-block padding in Classic, custom templates pick up the new value automatically — because the override didn’t touch that field.
  2. Small payload, easy validation on both ends. The backend only needs to validate a handful of whitelisted fields, not an entire template object.
  3. The “based on which template” semantic is preserved explicitly. The UI can tell the user “you’ve made 3 changes on top of Classic.”

Here’s the current editor UI, covering four high-frequency groups: colors, fonts, font sizes, and spacing:

Custom template editor


The runtime: from a single click to a finished export

The whole system has three pipelines.

Pipeline 1: live preview

The user changes a color in the editor and the preview must update immediately:

  1. The editor writes the new override values into localStorage.customDocumentTemplate.
  2. The editor dispatches a template-change event.
  3. MarkdownPreview listens for the event and calls getActiveTemplate().
  4. getActiveTemplate() reads documentTemplate (the current base) and customDocumentTemplate (the overrides). If they match, it calls createCustomTemplate(baseTemplate, overrides) to merge them into a complete template.
  5. generatePreviewCSS(template) regenerates the preview styles.

The merge logic is straightforward — a shallow merge per field group:

return {
  ...baseTemplate,
  id: `custom:${baseTemplate.id}`,
  colors: {
    ...baseTemplate.colors,
    ...(overrides.colors ?? {}),
  },
  fonts: {
    ...baseTemplate.fonts,
    ...(overrides.fonts ?? {}),
  },
  fontSizes: {
    ...baseTemplate.fontSizes,
    ...(overrides.fontSizes ?? {}),
  },
  spacing: {
    ...baseTemplate.spacing,
    ...(overrides.spacing ?? {}),
  },
};

Pipeline 2: saving as a private template

When the user clicks “Save Template,” the tool adapter packages up the current state:

{
  version: 1,
  toolSlug,
  documentTemplateId,
  customDocumentTemplate
}

This payload goes into saved_templates.settings_json. Note that documentTemplateId and customDocumentTemplate are stored separately — a private template means “Y modifications on top of template X,” not a style snapshot detached from its origin.

Pipeline 3: applying a private template

When the user picks one back from PrivateTemplateSelector, or loads it via the ?tpl=<id> URL parameter:

  1. The adapter calls applySettings(settingsJson).
  2. If the payload has a customDocumentTemplate, it’s written back to localStorage; otherwise it’s cleared.
  3. documentTemplateId is applied.
  4. template-change is dispatched, and the preview / export paths all see the merged complete template.

An easy-to-miss but critical detail: clear overrides when a system template is picked

We added a single line inside setActiveTemplate(templateId):

localStorage.removeItem('customDocumentTemplate');

Why? Without this, you get a confusing scenario:

  • User customizes Classic with a green heading and that’s stored in localStorage.
  • User switches to Dracula.
  • User switches back to Classic — and the heading is green again, but they don’t remember changing it.

We define “picking a system template” as an explicit reset: pick a clean template and you get the clean template. To customize again, click “Customize” again. That semantic is much clearer than “preserve the user’s last edits whenever possible.”

TemplatePickerModal runs the same cleanup when “Apply” is clicked.


Backend validation: the whitelist is the safety net

The backend validateTemplateInput() strictly validates settingsJson.customDocumentTemplate:

  • customDocumentTemplate is optional; if present it must be an object.
  • version must be 1 (room for migration later).
  • baseTemplateId must be a non-empty string.
  • overrides must be an object.
  • Fields not on the whitelist are rejected.

The whitelist covers everything the UI exposes today, plus a bit more headroom for future fields:

GroupAllowed fields
colorsheading, text, link, codeBackground, codeBorder, codeText, quoteText, quoteBorder, quoteBackground, tableHeaderBg, tableHeaderText, tableBorder, tableRowOdd, tableRowEven
fontsheading, body, code
fontSizesh1, h2, h3, h4, h5, h6, body, code
spacingheadingBefore, headingAfter, paragraphBefore, paragraphAfter, lineHeight

The whitelist is the safety net for this whole design: because we don’t accept arbitrary keys, even if we later add rendering logic to a field or rename it, old keys saved by users won’t leak into the runtime template and pollute the output.


The payoff: export paths don’t need to know “custom template” exists

After merging, you have a complete DocumentTemplate, so none of the downstream export functions need to change:

  • Preview: generatePreviewCSS(template)
  • PDF / print: generatePdfStyles() / generatePrintStyles()
  • HTML: generateExportHTML()
  • Rich-text copy: generateRichTextHTML()
  • Word: templateToDocxStyles(template)

This is exactly the payoff we got from refusing arbitrary CSS in the first place: as long as every output keeps consuming DocumentTemplate as a unified intermediate representation, cross-format consistency is essentially free. The heading color the user picks shows up identically in browser preview, PDF, Word, and image exports — not “approximately the same,” but literally the same hex value, because everything reads from one merged template.


Current limitations

The first version is intentionally narrow:

  • Only high-frequency fields are exposed; not every color, font size, or spacing value is editable.
  • There’s no dedicated “name your custom template” entry point — naming still happens through the “Save Template” dialog.
  • No JSON import / export for templates.
  • No arbitrary CSS.
  • The editor UI is a lightweight first pass — grouped panels, color presets, and mobile layout all have room to grow.

Where this is heading

A few clear next steps for this structure:

  • More editable fields. Table border color, alternating row backgrounds, quote background, code text color — the whitelist already has slots for these; only the UI is missing.
  • A unified field schema. Let the frontend form and backend validation share one definition so they don’t drift apart.
  • An “Edit” entry in the workspace template list. Right now you can only save a new copy; you can’t keep editing an existing private template.
  • An explicit “duplicate a system template into a private template” flow. Make “I want to base a private one on Classic” a more direct action.
  • Custom template JSON import / export. Useful for sharing styles across a team.

In summary

Looking back, the key to this design isn’t what we built — it’s what we didn’t build:

  • We didn’t allow arbitrary CSS — which avoided cross-format breakage.
  • We didn’t store full template snapshots — which preserved the benefit of base-template upgrades.
  • We didn’t silently keep overrides when switching system templates — which avoided the “I didn’t change anything but the style is different” confusion.
  • The backend doesn’t accept fields outside the whitelist — which leaves room for the design to evolve.

Constraints actually deliver a more predictable experience. In a tool that spans multiple export formats, that predictability is worth more than freedom.

If you’re also building a “one piece of content, many exports” product, head over to md-to.com and try this custom template system — see whether this is the kind of “just right” amount of freedom you’ve been looking for.