Angular

An Angular reactive-forms contact form that sends submissions to your inbox through HttpClient. No server needed.

An Angular app deployed with ng build is a folder of static files. To give it a working contact form without adding a backend, post the form to SilentForms with HttpClient; each submission is emailed to you. The examples use a standalone component, reactive forms and signals, and follow the current Angular style guide (file names without the .component suffix).

What you'll build#

A ContactForm standalone component that:

  • defines its fields with FormBuilder and Validators,
  • shows a message under each invalid field once it has been touched,
  • posts with HttpClient and shows sending, success and error states,
  • keeps the honeypot as a form control.

1. Put the key in the environment files#

Get a key on the access key page. If your project has no src/environments folder yet, create it with the CLI:

ng generate environments

This adds environment.ts (used by production builds) and environment.development.ts (used by ng serve), and the fileReplacements entry in angular.json that swaps them. Add the key to both files:

// src/environments/environment.ts and src/environments/environment.development.ts
export const environment = {
  silentformsKey: 'YOUR_ACCESS_KEY_HERE',
};

The key is compiled into the bundle, which is fine: it only decides whose inbox receives the submission. Committing the environment files is normal for public values like this one. If you use separate keys for testing and production, put a different key in each file.

2. Make HttpClient available#

Since Angular v21, HttpClient can be injected without any setup. On v20 and earlier, or if you want the fetch-based backend, register it in src/app/app.config.ts:

import { ApplicationConfig } from '@angular/core';
import { provideHttpClient, withFetch } from '@angular/common/http';

export const appConfig: ApplicationConfig = {
  providers: [provideHttpClient(withFetch())],
};

3. The component#

Create src/app/contact-form/contact-form.ts:

import { Component, inject, signal } from '@angular/core';
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { environment } from '../../environments/environment';

type Status = 'idle' | 'sending' | 'sent' | 'error';

@Component({
  selector: 'app-contact-form',
  imports: [ReactiveFormsModule],
  templateUrl: './contact-form.html',
})
export class ContactForm {
  private readonly http = inject(HttpClient);
  private readonly fb = inject(FormBuilder);

  protected readonly status = signal<Status>('idle');
  protected readonly message = signal('');

  protected readonly form = this.fb.nonNullable.group({
    name: ['', Validators.required],
    email: ['', [Validators.required, Validators.email]],
    message: ['', [Validators.required, Validators.minLength(10)]],
    botcheck: [false],
  });

  protected submit(): void {
    if (this.form.invalid) {
      this.form.markAllAsTouched();
      return;
    }
    this.status.set('sending');
    this.message.set('');

    this.http
      .post<{ message: string }>(
        'https://silentforms.com/api/submit',
        { ...this.form.getRawValue(), accessKey: environment.silentformsKey },
        { headers: { Accept: 'application/json' } },
      )
      .subscribe({
        next: () => {
          this.form.reset();
          this.status.set('sent');
          this.message.set('Thanks! Your message has been sent.');
        },
        error: (error: HttpErrorResponse) => {
          this.status.set('error');
          this.message.set(error.error?.message ?? 'Something went wrong. Please try again.');
        },
      });
  }
}

And its template, src/app/contact-form/contact-form.html:

<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
  <label for="cf-name">Name</label>
  <input id="cf-name" formControlName="name" type="text" autocomplete="name" />
  @if (form.controls.name.touched && form.controls.name.invalid) {
    <p class="error">Please tell us your name.</p>
  }

  <label for="cf-email">Email</label>
  <input id="cf-email" formControlName="email" type="email" autocomplete="email" />
  @if (form.controls.email.touched && form.controls.email.invalid) {
    <p class="error">Please enter a valid email address.</p>
  }

  <label for="cf-message">Message</label>
  <textarea id="cf-message" formControlName="message" rows="5"></textarea>
  @if (form.controls.message.touched && form.controls.message.invalid) {
    <p class="error">Please write at least 10 characters.</p>
  }

  <input type="checkbox" formControlName="botcheck" style="display: none" tabindex="-1" autocomplete="off" />

  <button type="submit" [disabled]="status() === 'sending'">
    {{ status() === 'sending' ? 'Sending…' : 'Send message' }}
  </button>

  <p [attr.role]="status() === 'error' ? 'alert' : 'status'">{{ message() }}</p>
</form>

Add ContactForm to the imports of the page component that shows it and use <app-contact-form /> in its template.

Angular details#

  • The body is JSON. Passing an object to http.post sends it as application/json, so SilentForms replies with JSON. On an error status, HttpErrorResponse.error holds the parsed body, which is where the visitor-friendly message lives ("Email address is invalid.", "Too many requests. Please try again in a minute."). For a network failure there is no body and the fallback text is shown.
  • getRawValue() on a nonNullable group returns plain strings and booleans, and reset() restores the initial values instead of setting everything to null.
  • Signals keep the view in sync. Recent Angular versions run without Zone.js by default, so plain class fields changed inside a subscribe callback may not re-render. Updating signals always does.
  • The honeypot sends false. Because botcheck is a control, the JSON always contains "botcheck": false for people. SilentForms treats that as empty; a bot that ticks the box gets a normal success response but nothing is emailed. See Honeypot.
  • novalidate turns off the browser's bubbles so the reactive-forms messages are the only ones shown. Angular adds it automatically to forms, but writing it makes the intent clear.

Testing#

  1. Run ng serve, click "Send message" with empty fields: the three messages appear and nothing is sent.
  2. Fill the form in and submit. The fields clear and the email arrives.
  3. Build with ng build and serve dist/<project>/browser with any static server. If the response is "Access key is required.", the key is missing from environment.ts (the production file), not just the development one.

Next steps#