Upgrade Guide

Upgrading your Maizzle projects from v5.x to v6.

Maizzle 6 is a complete rewrite. It is powered by Vite, uses Vue for templating and Tailwind CSS 4 for styling HTML emails.

Because of this, we recommend starting fresh with the v6 starter and porting templates over rather than upgrading in place. Skim this guide first so you know what changed, then start a new project with npx maizzle new and copy your templates and config over.

Install

Start a new project with the official starter:

bash
npx maizzle new

Or install into an existing project:

bash
npm install @maizzle/framework

Templates

Templates are now Vue SFCs (.vue files) instead of HTML files with PostHTML expressions. This means you can use Vue's full templating syntax, including components, directives, and JavaScript expressions. The frontmatter config is replaced by defineConfig() inside <script setup>.

File extension

diff
- emails/welcome.html
+ emails/welcome.vue

Structure

Here's a side-by-side of how you'd code the same email in v5 vs. v6:

---
title: World
---

<x-main>
  <table align="center" class="m-0 mx-auto">
    <tr>
      <td class="w-[552px] max-w-full">
        <h1 class="text-2xl">Hello, {{ name }}!</h1>
        <x-button
          href="https://maizzle.com"
          class="bg-slate-950 hover:bg-slate-800"
        >
          Get Started
        </x-button>
      </td>
    </tr>
  </table>
</x-main>
<script setup>
  const name = 'World'
</script>

<template>
  <Layout>
    <Container class="max-w-xl">
      <Heading class="text-2xl">Hello, {{ name }}!</Heading>
      <Button
        href="https://example.com"
        class="bg-slate-950 hover:bg-slate-800"
      >Get Started</Button>
    </Container>
  </Layout>
</template>

Notice:

  • No frontmatter — config goes inside <script setup> via defineConfig()
  • Built-in Vue components available (<Layout>, <Container>, <Heading>, <Button>)
  • Vue's {{ }} interpolation replaces PostHTML expressions

Expressions

PostHTML expressions are gone. Use Vue's template syntax:

PostHTML
Maizzle 5
Vue
Maizzle 6
{{ page.name }}{{ name }}
{{{ unsafe }}}<span v-html="unsafe" />
@{{ keep }}<span v-pre>{{ keep }}</span>
or <Raw>{{ keep }}</Raw>
<if condition="x"><div v-if="x">
<elseif condition="y"><div v-else-if="y">
<else><div v-else>
<each loop="item in items"><div v-for="item in items" :key="item.id">
<switch>/<case>use v-if/v-else-if chains
<scope with="...">use a child component or destructure in <script setup>

Layouts

The <x-main> pattern is replaced by Vue's component composition. Use the built-in <Layout> component, or wrap your own:

emails/welcome.vue
<template>
  <Layout>
    <Container>
      <Text>Your content here.</Text>
    </Container>
  </Layout>
</template>

yield → slot

<yield /> is replaced by Vue's <slot />:

components/MyLayout.vue
<template>
  <Layout>
    <yield />    <slot />  </Layout>
</template>

Markdown templates

.md files are now first-class entry templates with frontmatter, <script setup>, and a default layout wrapped around the content.

emails/update.md
---
title: Product Update
---

<script setup>
  usePreheader('We shipped some new features')
</script>

# Hello

Some **markdown** content with a Vue component:

<Button href="https://example.com">Read more</Button>

See Markdown templates for the full feature set.

Tailwind CSS 4

We have finally added support for Tailwind CSS 4 🥳

Bundled config

We now ship @maizzle/tailwindcss, our email-friendly Tailwind CSS 4 configuration.

Delete tailwind.config.js, you now configure Tailwind CSS 4 inside <style> tags:

emails/welcome.vue
<template>
  <Html>
    <Head>
      <style>
        @import "@maizzle/tailwindcss";

        @theme {
          --color-brand: #4f46e5;
          --font-display: "Inter", sans-serif;
        }
      </style>
    </Head>
    <Body>
      <Tailwind>
        <Text class="text-brand font-display">Hello!</Text>
      </Tailwind>
    </Body>
  </Html>
</template>

See our Tailwind CSS docs for more details and examples.

Email preset replaced

Components

New syntax

PostHTML components become Vue components.

Here's a side-by-side comparison of how you'd define the same component in v5 vs. v6:

<table align="center" class="mx-auto bg-indigo-100">
  <tr>
    <td class="p-6">
      <yield />
    </td>
  </tr>
</table>
<template>
  <table align="center" class="mx-auto bg-indigo-100">
    <tr>
      <td class="p-6">
        <slot />
      </td>
    </tr>
  </table>
</template>

Usage comparison:

<x-card>Limited time offer!</x-card>
<Card>Limited time offer!</Card>

Tailwind-first sizing

Most components are now styled and sized with Tailwind. For example, here are the new <Hr> divider and the vertical <Spacer>:

<x-divider height="2px" space-y="32px" color="#e2e8f0" />
<Hr class="h-0.5 my-8 bg-slate-200" />
<x-spacer height="32px" />
<Spacer class="h-8" />

Configuration

config.js becomes maizzle.config.ts, and it uses the composition API with defineConfig() for type safety and better editor support.

- config.js- config.production.js+ maizzle.config.ts
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  css: {
    minify: true,
  },
})

build key flattened

The whole build: { ... } wrapper is gone. Move its children to the root:

export default {
  build: {
    content: ['emails/**/*.html'],
    output: { path: 'build_production' },
  },
}
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  content: ['emails/**/*.{vue,md}'],
  output: { path: 'dist' },
})

CSS defaults flipped

css.inline, css.purge, css.shorthand, and html.format (former prettify) are now on by default. If your v5 project depended on them being off, disable them explicitly:

maizzle.config.ts
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  css: {
    inline: false,
    purge: false,
    shorthand: false,
  },
  html: {
    format: false,
  },
})

Remove PostHTML config

PostHTML is no longer used, so you can remove any related config keys like posthtml.*, expressions.*, and components.* from your config.

maizzle.config.ts
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  posthtml: { ... },  expressions: { ... },  components: { 
    source: ['custom-components'],    folders: ['custom-components'],    // everything else removed  },
}

Fetch tag removed

The PostHTML <fetch> tag has been removed, use fetch() (or any HTTP client) inside <script setup> and bind the result:

emails/news.vue
<script setup>
  const items = await fetch('https://api.example.com/news').then(r => r.json())
</script>

<template>
  <Layout>
    <Container>
      <Text v-for="item in items" :key="item.id">{{ item.title }}</Text>
    </Container>
  </Layout>
</template>

Outlook config

The outlook config key has been removed. Use the built-in <Outlook> component instead:

emails/example.vue
<template>
  <Outlook>
    <Text>Visible only in Outlook.</Text>
  </Outlook>
</template>

Other renamed keys

Some other config keys have been renamed for clarity. Here's a quick reference:

Maizzle 5.xMaizzle 6
attributes.addhtml.attributes.add
attributes.removehtml.attributes.remove
prettifyhtml.format
minifyhtml.minify

Plaintext config

The plaintext config shape changed in v6. The string shorthand and the nested output key are gone — destination, extension, and strip-HTML options are now flat keys on a single object.

String shorthand → destination:

export default {
  plaintext: 'dist/brand/plaintext',
}
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  plaintext: {
    destination: 'dist/brand/plaintext',
  },
})

output.path and output.extension → flat destination and extension:

export default {
  plaintext: {
    output: {
      path: 'dist/brand/plaintext',
      extension: 'rtxt',
    },
  },
}
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  plaintext: {
    destination: 'dist/brand/plaintext',
    extension: 'rtxt',
  },
})

Strip-HTML options now live under a dedicated options key:

maizzle.config.ts
import { defineConfig } from '@maizzle/framework'

export default defineConfig({
  plaintext: {
    options: { ignoreTags: ['br'] },
  },
})

To enable plaintext for a single template, use the usePlaintext() composable instead of frontmatter:

---
plaintext: true
---

<x-main>
  <!-- ... -->
</x-main>
<script setup>
  usePlaintext()
</script>

<template>
  <Layout>
    <!-- ... -->
  </Layout>
</template>

See the Plaintext docs for the full guide.

The permalink frontmatter key, which sent a template to a custom output path, is now the useOutputPath() composable:

---
permalink: out/promos/black-friday.html
---

<x-main>
  <!-- ... -->
</x-main>
<script setup>
  useOutputPath('out/promos/black-friday.html')
</script>

<template>
  <Layout>
    <!-- ... -->
  </Layout>
</template>

The path is still relative to your project root and behaves the same as in v5.

Per-template config

In v5, you'd set per-template config via frontmatter. In v6, call defineConfig() inside <script setup>:

emails/plain.vue
<script setup>
  defineConfig({
    css: { inline: false },
  })
</script>

Or use the dedicated composables for common cases:

Events

Events still register at the root of the config, but the signatures have changed.

afterTransformers renamed to afterTransform

afterTransformers({ html, matter, config }) {
  return html.replace('</body>', '<img src="..." />\n</body>')
}
import { defineConfig } from '@maizzle/framework'

afterTransform({ html, template, config }) {
  return html.replace('</body>', '<img src="..." />\n</body>')
}

matter is gone

v5 handlers received matter (the frontmatter object). v6 has no frontmatter — config lives inside <script setup> via defineConfig(), and template-level config is on the resolved config argument that handlers already receive.

beforeRender now operates on the SFC source

In v5, beforeRender({ html, ... }) received the pre-rendered HTML and you returned a modified HTML string. In v6, the renderer is Vue SSR, so beforeRender({ template, config }) receives the raw .vue SFC source instead — return a string to replace template.source before it's handed to the renderer.

beforeRender({ html }) {
  return html.replace('FOO', 'BAR')
}
beforeRender({ template }) {
  return template.source.replace('FOO', 'BAR')
}

template argument

afterRender and afterTransform now also receive a template argument — same shape as the one passed to beforeRender:

ts
interface TemplateInfo {
  source: string         // raw Vue SFC source
  path: ParsedPath       // result of path.parse(absolutePath)
}

config.build.current is gone

v5 exposed the currently-building template path on config.build.current.path (also a path.parse() result, mutated onto the shared config). v6 drops that and surfaces the same info two ways:

  • In event handlers, via template.path (e.g. template.path.name === 'newsletter').
  • Anywhere inside an SFC, via the new useCurrentTemplate() composable.
beforeRender({ config }) {
  if (config.build.current.path.name === 'newsletter') {
    // ...
  }
}
beforeRender({ template }) {
  if (template.path.name === 'newsletter') {
    // ...
  }
}

See Events for the full list and signatures.

CLI commands

v5v6
maizzle buildmaizzle build (or programmatic build())
maizzle build productionmaizzle build -c production.config.ts
maizzle servemaizzle serve or maizzle dev
maizzle make:template namemaizzle make:template [filepath]

Use built-in components

Maizzle 6 ships polished, render-tested email building blocks: <Button>, <Container>, <Heading>, <Hr>, <Img>, <Spacer>, <Text>, and more.

Replace your hand-coded tables with these where you can — they're heavily tested (we've been using them in production for years), they handle Outlook quirks for you, and LLMs can understand them better when asked to generate emails.

Vite plugin

If your project already uses Vite (Laravel, Nuxt, SvelteKit, Astro etc.), you can run Maizzle as a plugin alongside your app instead of as a standalone project. See Framework Guides.