# Send email from an Angular contact form

An Angular contact form that emails you every submission without a Node server or email API. A standalone component with reactive forms and signals.

Updated Oct 29, 2026.

The usual advice for sending email from an Angular contact form is a Node server with Nodemailer, or EmailJS. The first means hosting a server and guarding SMTP credentials for one form. EmailJS sends from the browser, but you manage its templates and monthly quota and add your own spam checks.

The component below posts to a Nisuform endpoint instead. The endpoint filters spam, keeps every submission in an inbox and emails you, and your app stays a static build.

## The component

```ts title="contact-form.component.ts"
import { Component, inject, signal } from '@angular/core'
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'

const endpoint = 'https://api.nisuform.com/s/YOUR_FORM_KEY'

@Component({
  selector: 'app-contact-form',
  imports: [ReactiveFormsModule],
  templateUrl: './contact-form.component.html',
})
export class ContactFormComponent {
  private readonly shownAt = Date.now()
  readonly status = signal<'idle' | 'sending' | 'sent' | 'error'>('idle')
  readonly error = signal('')
  readonly form = inject(FormBuilder).nonNullable.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
    message: ['', Validators.required],
    _gotcha: [''],
  })

  async submit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched()
      return
    }
    this.status.set('sending')
    try {
      const response = await fetch(endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
        body: JSON.stringify({ ...this.form.getRawValue(), _elapsed: String(Date.now() - this.shownAt) }),
      })
      const result = await response.json()
      if (result.ok) {
        this.status.set('sent')
      } else {
        this.error.set(result.message)
        this.status.set('error')
      }
    } catch {
      this.error.set('Something went wrong. Please try again.')
      this.status.set('error')
    }
  }
}
```

```html title="contact-form.component.html"
@if (status() === 'sent') {
  <p role="status">Thanks! We got your message.</p>
} @else {
  <form [formGroup]="form" (ngSubmit)="submit()">
    <label>
      Name
      <input formControlName="name" type="text" autocomplete="name" />
    </label>
    <label>
      Email
      <input formControlName="email" type="email" autocomplete="email" />
    </label>
    <label>
      Message
      <textarea formControlName="message" rows="5"></textarea>
    </label>
    <input formControlName="_gotcha" type="text" tabindex="-1" autocomplete="off" style="display: none" />
    <button type="submit" [disabled]="status() === 'sending'">
      {{ status() === 'sending' ? 'Sending...' : 'Send' }}
    </button>
    @if (status() === 'error') {
      <p role="alert">{{ error() }}</p>
    }
  </form>
}
```

Angular validates the fields before anything leaves the browser. The control names become the JSON keys, which are the names your answers are saved under. Signals update the view after the `await`, with or without Zone.js.

`_gotcha` is a hidden honeypot that only bots fill in, and `_elapsed` is how long the form was open. Nisuform drops submissions that trip either one and never saves the two fields.

To connect it, [create a form](https://app.nisuform.com), paste the key from its **Setup** tab into `endpoint`, and add `ContactFormComponent` to the `imports` of the page that renders `<app-contact-form />`. Run `ng serve`, wait two seconds and send a test. It shows up in your inbox and your email within seconds.



## With HttpClient

If your app already provides `HttpClient`, you can use it instead of `fetch`. It turns a rejected submission into an error, so the message to show is on the error body:

```ts title="With HttpClient"
this.http
  .post<{ ok: boolean }>(endpoint, body, { headers: { Accept: 'application/json' } })
  .subscribe({
    next: () => this.status.set('sent'),
    error: (response) => {
      this.error.set(response.error?.message ?? 'Something went wrong. Please try again.')
      this.status.set('error')
    },
  })
```

## Hosting

`ng build` outputs static files and the form posts from the browser, so Firebase Hosting, Netlify, Vercel, Cloudflare Pages and GitHub Pages all work. If your app sends a Content Security Policy, add `https://api.nisuform.com` to `connect-src`. When the site is live, add `http://localhost:4200` and your domain to the form's [allowed websites](/docs/protection/access-rules).

## Guides

- [Submit a form with fetch](https://nisuform.com/docs/connect/javascript)
- [Special fields](https://nisuform.com/docs/connect/special-fields)

## Frequently asked questions

### Can Angular send an email directly?

No. Angular runs in the visitor's browser, and browsers can't send email. You need server code with an email provider, or a form backend endpoint that receives the form, stores it and emails you.

### Do I need HttpClient?

No. The component uses the browser's fetch, so you don't have to add provideHttpClient. HttpClient works too, but it reports a rejected submission as an error, so read the message from the error body.

### Does it work with Angular SSR or prerendering?

Yes. The page can be rendered on the server or prerendered. The submit only runs in the browser after hydration, so the form doesn't depend on your server.

### Does it work with older Angular versions?

The example uses standalone components, signals and the @if block, which need Angular 17 or later. On older versions, use an NgModule, class properties and *ngIf. The request to the endpoint stays the same.

### Is it safe to put the endpoint in my Angular code?

Yes. The form key only accepts new submissions. Nobody can read your submissions or change the form with it. Add your domains to the form's allowed websites so other sites can't post to it.
