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
FormBuilderandValidators, - shows a message under each invalid field once it has been touched,
- posts with
HttpClientand 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 environmentsThis 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.postsends it asapplication/json, so SilentForms replies with JSON. On an error status,HttpErrorResponse.errorholds 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 anonNullablegroup returns plain strings and booleans, andreset()restores the initial values instead of setting everything tonull.- Signals keep the view in sync. Recent Angular versions run without Zone.js by default, so plain class fields changed inside a
subscribecallback may not re-render. Updating signals always does. - The honeypot sends
false. Becausebotcheckis a control, the JSON always contains"botcheck": falsefor people. SilentForms treats that as empty; a bot that ticks the box gets a normal success response but nothing is emailed. See Honeypot. novalidateturns 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#
- Run
ng serve, click "Send message" with empty fields: the three messages appear and nothing is sent. - Fill the form in and submit. The fields clear and the email arrives.
- Build with
ng buildand servedist/<project>/browserwith any static server. If the response is "Access key is required.", the key is missing fromenvironment.ts(the production file), not just the development one.
Next steps#
- Send each submission to your own systems: Webhooks.
- Add a CAPTCHA: Google reCAPTCHA.
- Stop the key being reused elsewhere: Restrict to your domain.