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

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 with EmailRenderError. The renderer does not repair or wrap an incomplete document.
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 files compiled in a dedicated Nitro Rollup graph. Each render uses a fresh Vue server-side rendering (SSR) application. Vue compiler macros such as defineProps, defineOptions, and defineSlots remain available without imports. Import other 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 these features inside a template:

  • bare APIs such as ref;
  • useNuxtApp or context-dependent composables;
  • application plugins; or
  • app-level provides.

Pass application data through typed props. Import standalone utilities explicitly. This boundary keeps output deterministic and lets the package testing helper use the same component.

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 { EmailComponentProps } from '@lupinum/nuxt-email'
import type WelcomeEmail from './welcome.vue'

export default {
  firstName: 'Ada',
  activationUrl: 'https://example.com/activate',
} satisfies EmailComponentProps<typeof WelcomeEmail>

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, open the development preview and inspect the fixture.