Modules

Email Templates

Vue email templates for Notify, rendered with nuxt-email-renderer - typed sendEmailTemplate for the host, and module emails the host can override by file name.

Emails written as Vue components, rendered with nuxt-email-renderer and sent through Notify, as a Nuxt module. It does two things:

  • The host sends its own templates with a typed sendEmailTemplate(name, props, message).
  • Modules (Auth, Users, Subscriptions) ship a template for every email they send. With this module installed, those emails are rendered from the templates. Without it, they go out as the plain HTML each module builds. The host overrides any module template by adding a file with the same name.

All the Vue rendering lives here: Notify and the feature modules stay free of Vue SSR.

Requires@frigear-nu/module-kit, @frigear-nu/notify and nuxt-email-renderer; all are installed automatically.

Quick setup

export default defineNuxtConfig({
  modules: ['@frigear-nu/email-templates'],
})
The module emails (auth, users, subscriptions) only use their Vue templates when the host lists @frigear-nu/email-templates in modules. Installed as a dependency of another module is not enough: without it in modules, notify sends the plain HTML fallback.

Add templates to app/emails (or emails/, or the nuxtEmailRenderer.emailsDir option). Every top-level .vue file is a template, named by its file name. Build them from the components so they match the module emails:

app/emails/welcome.vue
<script setup lang="ts">
import { EmailButton, EmailDivider, EmailHeading, EmailLayout, EmailLinkFallback, EmailText } from '@frigear-nu/email-templates/components'

defineProps<{ name: string, url: string }>()
</script>

<template>
  <EmailLayout :preview="`Welcome, ${name}!`">
    <ESubject>Welcome, {{ name }}</ESubject>
    <EmailHeading>Welcome, {{ name }}!</EmailHeading>
    <EmailDivider />
    <EmailText>Thanks for signing up. Click the button below to get started.</EmailText>
    <EmailButton :href="url">Get started</EmailButton>
    <EmailLinkFallback :href="url" />
  </EmailLayout>
</template>

The E* components, <ESubject>, i18n ($t with @nuxtjs/i18n), globalCss and the DevTools preview all come from nuxt-email-renderer. This module sets its codeHighlighting default to false, which keeps Shiki out of the server bundle. Set nuxtEmailRenderer.codeHighlighting: true if you need <ECodeBlock>.

Components

Styled building blocks, imported from @frigear-nu/email-templates/components. You have to import them: the renderer registers only its own E* components globally. They use inline styles and table layout, because email clients do not support CSS classes. Mix them freely with the renderer's E* components.

Every email has the same shape: a grey page, a white card with the logo header, the content, and a footer.

ComponentPropsRenders
EmailLayoutpreview?, lang? (en); slots: default, footerThe whole document: page, card, EmailHeader and EmailFooter (replace it with the footer slot)
EmailHeaderlogoUrl?Centred logo, 150px wide, or the brand name when no logo is configured
EmailHeadingtone?: 'default' | 'danger'The h2 title; danger for security alerts
EmailTextvariant?: 'default' | 'muted' | 'small'A paragraph: body text, secondary text, fine print
EmailButtonhref, color?: 'primary' | 'danger'Call-to-action button
EmailLinkFallbackhref; slot for the lead-in"Or copy this link to your browser: …"
EmailDividernoneHorizontal rule
EmailFooterslot"This email was sent automatically for security reasons."

Branding comes from the module options. Each option can also be set at runtime:

export default defineNuxtConfig({
  emailTemplates: {
    brandName: 'Frigear', // logo alt text, and the header when there is no logo
    logoUrl: '/logo.png', // absolute, or resolved against siteUrl
    siteUrl: 'https://frigear.nu',
    colors: { primary: '#2563eb', danger: '#dc2626' },
  },
})
Environment variableOption
NUXT_EMAIL_TEMPLATES_BRAND_NAMEbrandName
NUXT_EMAIL_TEMPLATES_LOGO_URLlogoUrl
NUXT_EMAIL_TEMPLATES_SITE_URLsiteUrl
NUXT_EMAIL_TEMPLATES_COLORS_PRIMARY, _DANGERcolors.primary, colors.danger

Use an absolute URL for the logo: mail clients cannot resolve relative paths. The previews on this page use the default colours and the Frigear logo.

Composing an email

Every template starts from EmailLayout, which already draws the page, the card, the logo and the footer. Inside it come the blocks, usually in this order: <ESubject>, an EmailHeading, an EmailDivider, then EmailText paragraphs and an EmailButton followed by an EmailLinkFallback. A few patterns go beyond that:

  • Tables and columns. The renderer's ERow and EColumn sit next to the components. Use them for key/value details or line items, since email clients do not support flexbox or grid.
  • Fine-tuning a block. A style you pass merges into the component's own, for example style="margin: 0" to tighten a list.
  • Formatting. Templates render outside the Nuxt app, so app composables such as useFormat() are not available. Format numbers and dates with Intl, or pass them in already formatted.
  • Your own footer. Fill the layout's footer slot with an EmailFooter, or anything else, for example to add an unsubscribe link.

A security alert, with a details table and a danger heading and button:

app/emails/new-sign-in.vue
<script setup lang="ts">
import { EmailButton, EmailDivider, EmailHeading, EmailLayout, EmailLinkFallback, EmailText } from '@frigear-nu/email-templates/components'

defineProps<{
  name: string
  device: string
  location: string
  signedInAt: string
  secureUrl: string
}>()
</script>

<template>
  <EmailLayout preview="New sign-in to your account">
    <ESubject>New sign-in to your account</ESubject>
    <EmailHeading tone="danger">
      New sign-in detected
    </EmailHeading>
    <EmailDivider />
    <EmailText>Hi {{ name }}, your account was just signed in to from a new device.</EmailText>

    <!-- Renderer components mix in freely: a two-column details table. -->
    <ERow
      v-for="[label, value] in [['Device', device], ['Location', location], ['Time', signedInAt]]"
      :key="label"
    >
      <EColumn style="width: 35%; padding: 4px 0">
        <EmailText
          variant="muted"
          style="margin: 0"
        >
          {{ label }}
        </EmailText>
      </EColumn>
      <EColumn style="padding: 4px 0">
        <EmailText style="margin: 0">
          {{ value }}
        </EmailText>
      </EColumn>
    </ERow>

    <EmailDivider />
    <EmailText>If this was you, no action is needed. If not, secure your account now.</EmailText>
    <EmailButton
      :href="secureUrl"
      color="danger"
    >
      Secure my account
    </EmailButton>
    <EmailLinkFallback :href="secureUrl" />
  </EmailLayout>
</template>

A receipt, with line items, a computed total and a footer of its own:

app/emails/order-receipt.vue
<script setup lang="ts">
import { computed } from 'vue'
import { EmailButton, EmailDivider, EmailFooter, EmailHeading, EmailLayout, EmailText } from '@frigear-nu/email-templates/components'

const props = defineProps<{
  name: string
  orderNumber: string
  items: { label: string, quantity: number, amount: number }[]
  currency: string
  orderUrl: string
}>()

// Templates render outside the Nuxt app, so format with Intl rather than app composables.
const money = (amount: number) => new Intl.NumberFormat('en', { style: 'currency', currency: props.currency }).format(amount)
const total = computed(() => props.items.reduce((sum, item) => sum + item.amount * item.quantity, 0))
</script>

<template>
  <EmailLayout :preview="`Receipt for order ${orderNumber}`">
    <ESubject>Your receipt for order {{ orderNumber }}</ESubject>
    <EmailHeading>Thanks for your order, {{ name }}!</EmailHeading>
    <EmailText variant="muted">
      Order {{ orderNumber }}
    </EmailText>
    <EmailDivider />

    <ERow
      v-for="item in items"
      :key="item.label"
    >
      <EColumn>
        <EmailText style="margin: 0 0 8px">
          {{ item.quantity }} × {{ item.label }}
        </EmailText>
      </EColumn>
      <EColumn align="right">
        <EmailText style="margin: 0 0 8px">
          {{ money(item.amount * item.quantity) }}
        </EmailText>
      </EColumn>
    </ERow>

    <EmailDivider />
    <ERow>
      <EColumn>
        <EmailText style="font-weight: 700; margin: 0 0 16px">
          Total
        </EmailText>
      </EColumn>
      <EColumn align="right">
        <EmailText style="font-weight: 700; margin: 0 0 16px">
          {{ money(total) }}
        </EmailText>
      </EColumn>
    </ERow>

    <EmailButton :href="orderUrl">
      View order
    </EmailButton>

    <template #footer>
      <EmailFooter>Questions about your order? Just reply to this email.</EmailFooter>
    </template>
  </EmailLayout>
</template>

A digest with no button: a list of links, an empty state, and an unsubscribe link in the footer:

app/emails/weekly-digest.vue
<script setup lang="ts">
import { EmailDivider, EmailFooter, EmailHeading, EmailLayout, EmailText } from '@frigear-nu/email-templates/components'

defineProps<{
  name: string
  events: { title: string, date: string, url: string }[]
  unsubscribeUrl: string
}>()
</script>

<template>
  <EmailLayout preview="Upcoming events this week">
    <ESubject>This week at Frigear</ESubject>
    <EmailHeading>Hi {{ name }}, here’s what’s coming up</EmailHeading>
    <EmailDivider />

    <template
      v-for="event in events"
      :key="event.url"
    >
      <EmailText style="margin: 0 0 4px; font-weight: 600">
        <ELink
          :href="event.url"
          style="color: inherit"
        >
          {{ event.title }}
        </ELink>
      </EmailText>
      <EmailText variant="muted">
        {{ event.date }}
      </EmailText>
    </template>

    <EmailText v-if="!events.length">
      Nothing scheduled this week. We’ll let you know when something comes up.
    </EmailText>

    <!-- Replace the default footer, e.g. with an unsubscribe link. -->
    <template #footer>
      <EmailFooter>
        You get this digest because you follow Frigear events.
        <a
          :href="unsubscribeUrl"
          style="color: inherit"
        >Unsubscribe</a>.
      </EmailFooter>
    </template>
  </EmailLayout>
</template>

Send them like any other template. The props are typed from each defineProps:

await sendEmailTemplate('order-receipt', {
  name: user.name,
  orderNumber: order.number,
  currency: 'DKK',
  items: order.lines.map(line => ({ label: line.label, quantity: line.quantity, amount: line.amount })),
  orderUrl: `${origin}/orders/${order.number}`,
}, { to: user.email })

Server

sendEmailTemplate and renderEmailTemplate are auto-imported in server code.

await sendEmailTemplate('welcome', { name: user.name, url }, {
  to: user.email,
  replyTo: 'support@frigear.nu',
})
FunctionSignature
sendEmailTemplate(name, props, message) => Promise<void>. Renders, then sends with dispatchEmailNotification.
renderEmailTemplate(name, props, { locale?, text? }) => Promise<{ subject?, body, text? }>. Renders without sending.

message is an EmailNotification without body (to, replyTo, meta), plus:

FieldPurpose
subjectOverrides the template's <ESubject>. Required when the template has none.
textYour own plain-text body, or false to send HTML only. By default it is rendered from the template.
localeLocale to render with, when @nuxtjs/i18n is installed.

The template names are typed, and so are each template's props. Both are generated into .nuxt/types/email-templates.d.ts from every template the renderer will see, so a misspelled name or a missing prop does not compile. Adding or removing a template in dev regenerates them.

Module emails

A module dispatches its email exactly as before, with the plain HTML as the fallback, and names its template:

await dispatchEmailNotification({
  to,
  subject: 'Your sign-in link',
  body: `<p>…</p>`,
  text: `…`,
  template: { name: 'auth-magic-link', props: { name, url } },
})

The module ships the template, built from the components, and registers its directory with Notify's notify:email:templates build hook. That hook lives in Notify, so the module does not depend on this one:

// module setup()
nuxt.hook('notify:email:templates', sources => sources.push({ dir: resolver.resolve('./runtime/emails') }))

This module listens on Notify's notify:email runtime hook. When the notification names a template that exists, it renders it and replaces subject (when the template has <ESubject>), body and text. If the template is missing, or it throws or renders nothing, the error is logged and the fallback is sent. A broken template never blocks a sign-in link.

Name module templates <module>-<email>.vue so they do not collide.

Overriding a module template

Add a template with the same name to the app (or any layer) and it replaces the module's. Use the same props, and reuse the components to keep the look. To only change the branding, use the options instead.

app/emails/auth-magic-link.vue
<script setup lang="ts">
import { EmailButton, EmailHeading, EmailLayout, EmailLinkFallback, EmailText } from '@frigear-nu/email-templates/components'

defineProps<{ name: string, url: string }>()
</script>

<template>
  <EmailLayout lang="da" preview="Dit login-link">
    <ESubject>Log ind på Frigear</ESubject>
    <EmailHeading>Hej {{ name }}</EmailHeading>
    <EmailText>Klik på knappen for at logge ind.</EmailText>
    <EmailButton :href="url">Log ind</EmailButton>
    <EmailLinkFallback :href="url">Eller kopier linket:</EmailLinkFallback>
  </EmailLayout>
</template>

Precedence, lowest to highest: module templates, then the layers' templates, then the app's.

TemplateSent byProps
auth-magic-linkAuth: magic-link sign-in{ name, url }
auth-verify-emailAuth: verify the address after registering{ name, url }
auth-password-resetAuth: forgot password{ url }
users-verify-email-changeUsers: confirm a new email address{ name, url }
users-email-changedUsers: notice to the old address, with an undo link{ name, newEmail, url }
users-password-changedUsers: password changed notice{ name }
subscriptions-payment-linkSubscriptions: staff-sent payment link{ name, label, url }

Bundle and runtime notes

  • Rendering bundles Vue SSR and the template components into the Nitro server. Templates that a published module ships are inlined, so the Vue plugin compiles them.
  • nuxt-email-renderer has no option for template directories outside the layers. This module therefore rebuilds its #email-templates virtual module with the module templates added underneath. It does not work with experimental.nitroViteEnvironment yet: in that mode the renderer only compiles .vue files from the layer directories.
Copyright © 2026