Skip to main content

Your first email

Write a Vue email template under app/emails/, add a typed preview fixture, and understand template naming.

Every .vue file under app/emails/ is an email template. Nested paths become slash-separated names; app/emails/account/reset-password.vue is named account/reset-password. The app/emails/components/ folder is reserved for your own supporting components and is not discovered as a template.

Write the template

app/emails/welcome.vue
<script setup lang="ts">
import { defineEmail } from '@lupinum/nuxt-email/define-email'

const props = defineProps<{
  firstName: string
  activationUrl: string
}>()

defineEmail({
  subject: () => `Welcome aboard, ${props.firstName}`,
})
</script>

<template>
  <ETailwind>
    <EHtml lang="en">
      <EHead>
        <title>Activate your account</title>
      </EHead>
      <EBody class="m-0 bg-slate-100 p-6">
        <EPreview>Your account is ready.</EPreview>
        <EContainer class="rounded-lg bg-white p-6">
          <EHeading class="m-0 text-2xl text-slate-900">Welcome, {{ firstName }}</EHeading>
          <EText class="text-slate-600">Finish setting up your account.</EText>
          <EButton class="rounded-md bg-blue-600 px-5 py-3 text-white" :href="activationUrl">
            Activate account
          </EButton>
        </EContainer>
      </EBody>
    </EHtml>
  </ETailwind>
</template>

You do not import the E* components — the module auto-imports eighteen built-ins and adds ECodeBlock when configured. Use normal defineProps(), slots, v-if, v-for, Tailwind v4 classes, HTML attributes, and Vue style bindings. defineEmail is an explicit server-only import; its closure captures the template's actual props.

Standalone renderer boundary

Email templates are Vue SFCs compiled in a dedicated Nitro Rollup graph and rendered in a fresh Vue SSR application. Vue compiler macros such as defineProps, defineOptions, and defineSlots remain available without imports. Import non-macro Vue APIs explicitly:

vue
<script setup lang="ts">
import { computed } from 'vue'

const props = defineProps<{ firstName: string }>()
const greeting = computed(() => `Welcome, ${props.firstName}`)
</script>

Nuxt app auto-imports and host application context are not part of the email-rendering contract. Do not rely on bare ref, useNuxtApp, application plugins, app-level provides, or context-dependent composables inside a template. Pass application data through typed props and explicitly import standalone utilities. This boundary keeps output deterministic and makes the same component usable through the package testing helper.

Complete-document requirement
A template must render exactly one <html> root containing exactly one <body>. EHtml and EBody are the supported wrappers. Fragments, text-only roots, and multiple roots fail rendering with EmailRenderError. The renderer never auto-wraps or repairs a template.

Add a preview fixture

To see the template in the development preview, add one exact sibling .fixtures.ts file with deterministic sample props:

app/emails/welcome.fixtures.ts
import type WelcomeEmail from './welcome.vue'

type WelcomeEmailProps = Omit<
  InstanceType<typeof WelcomeEmail>['$props'],
  keyof import('vue').PublicProps
>

export default {
  firstName: 'Ada',
  activationUrl: 'https://example.com/activate',
} satisfies WelcomeEmailProps

The file must default-export one object, and only the exact .fixtures.ts suffix is recognized. The satisfies expression makes fixture values obey the same prop type used by the generated render API. Fixtures are excluded from production builds — never import them into production code.

Next, render the template from Nitro.