Angular
Every example on this page is a self-contained standalone Angular component using fetch, with no BootForm SDK and no HttpClient setup needed. Create each one with ng generate component (for example ng generate component contact-form, which makes contact-form.ts and contact-form.html), then replace the generated files with the code below. Jump to just the one you need from the outline on the right, or read top to bottom for the full picture.
New Angular apps are zoneless, so the examples keep their state in signals: a plain class field changed after an await would never update the page.
Basic submission
The plain HTML case works unchanged inside a template, with an empty component class around it:
// contact-form.ts
import { Component } from '@angular/core'
@Component({
selector: 'app-contact-form',
templateUrl: './contact-form.html',
})
export class ContactForm {}<!-- contact-form.html -->
<form action="https://f.bootform.com/{form_id}" method="POST">
<!-- Required: BootForm's spam trap. Keep this hidden field and leave it empty. -->
<input type="text" name="honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true" />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message"></textarea>
<button type="submit">Send</button>
</form>The hidden honeypot input is BootForm's spam trap. New forms use the name honeypot; if you renamed yours, use the name in your form's snippet (in the dashboard, or from bootform_get_snippet over MCP). See Spam protection.
AJAX submission
Intercept the submit, post with fetch, and track status with signals instead of navigating away. The try/catch covers a failed network request, so the button never stays stuck on "Sending…":
// contact-form.ts
import { Component, signal } from '@angular/core'
@Component({
selector: 'app-contact-form',
templateUrl: './contact-form.html',
})
export class ContactForm {
status = signal<'idle' | 'sending' | 'sent' | 'error'>('idle')
error = signal<string | null>(null)
async handleSubmit(event: SubmitEvent) {
event.preventDefault()
const form = event.target as HTMLFormElement
this.status.set('sending')
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form),
})
const result = await response.json()
if (result.ok) {
form.reset()
this.status.set('sent')
} else {
this.error.set(result.error)
this.status.set('error')
}
} catch {
this.error.set('Could not send. Check your connection and try again.')
this.status.set('error')
}
}
}<!-- contact-form.html -->
<form action="https://f.bootform.com/{form_id}" method="POST" (submit)="handleSubmit($event)">
<!-- Required: BootForm's spam trap. Keep this hidden field and leave it empty. -->
<input type="text" name="honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true" />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="message">Message</label>
<textarea id="message" name="message"></textarea>
<button type="submit" [disabled]="status() === 'sending'">
{{ status() === 'sending' ? 'Sending…' : 'Send' }}
</button>
@if (status() === 'sent') {
<p role="status">Thanks, your message was sent.</p>
}
@if (status() === 'error') {
<p role="alert">{{ error() }}</p>
}
</form>File upload
FormData picks up a file input the same way as text fields; nothing Angular-specific needed beyond the signals you're already tracking above:
// upload-form.ts
import { Component, signal } from '@angular/core'
@Component({
selector: 'app-upload-form',
templateUrl: './upload-form.html',
})
export class UploadForm {
status = signal<'idle' | 'sending' | 'sent' | 'error'>('idle')
error = signal<string | null>(null)
async handleSubmit(event: SubmitEvent) {
event.preventDefault()
const form = event.target as HTMLFormElement
this.status.set('sending')
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(form), // includes the selected file automatically
})
const result = await response.json()
this.error.set(result.ok ? null : result.error)
this.status.set(result.ok ? 'sent' : 'error')
} catch {
this.error.set('Could not send. Check your connection and try again.')
this.status.set('error')
}
}
}<!-- upload-form.html -->
<form action="https://f.bootform.com/{form_id}" method="POST" (submit)="handleSubmit($event)">
<!-- Required: BootForm's spam trap. Keep this hidden field and leave it empty. -->
<input type="text" name="honeypot" style="position:absolute;left:-9999px" tabindex="-1" autocomplete="off" aria-hidden="true" />
<label for="email">Email</label>
<input id="email" name="email" type="email" required />
<label for="resume">Resume</label>
<input id="resume" name="resume" type="file" required />
<button type="submit" [disabled]="status() === 'sending'">
{{ status() === 'sending' ? 'Sending…' : 'Send' }}
</button>
@if (status() === 'sent') {
<p role="status">Thanks, your file was sent.</p>
}
@if (status() === 'error') {
<p role="alert">{{ error() }}</p>
}
</form>A file upload requires the form to be claimed first, or you get 422 FORM_NOT_CLAIMED. See Building your form.
Moderated content feed
Load a form's approved submissions on init, and let visitors react. The feed endpoint is public and unauthenticated, no API key involved. A form without a feed (unclaimed, below Pro, or a mistyped id) answers with an error status instead, so the component checks response.ok and keeps the list empty:
// content-feed.ts
import { Component, OnInit, input, signal } from '@angular/core'
interface FeedItem {
id: string
fields: Record<string, string>
reactions: { up: number; down: number }
}
@Component({
selector: 'app-content-feed',
templateUrl: './content-feed.html',
})
export class ContentFeed implements OnInit {
formId = input.required<string>()
items = signal<FeedItem[]>([])
async loadFeed() {
const response = await fetch(`https://f.bootform.com/forms/${this.formId()}/feed?per_page=20`)
if (!response.ok) return // no feed for this form: unclaimed, below Pro, or a wrong id
const { items } = await response.json()
this.items.set(items)
}
async react(submissionId: string, type: 'up' | 'down') {
await fetch(`https://f.bootform.com/forms/${this.formId()}/feed/${submissionId}/react`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type }),
})
this.loadFeed() // refetch to show the updated count
}
ngOnInit() {
this.loadFeed()
}
}<!-- content-feed.html -->
<ul>
@for (item of items(); track item.id) {
<li>
<p>{{ item.fields['message'] }}</p>
<button (click)="react(item.id, 'up')">👍 {{ item.reactions.up }}</button>
<button (click)="react(item.id, 'down')">👎 {{ item.reactions.down }}</button>
</li>
}
</ul>Use it as <app-content-feed formId="{form_id}" />.
See Moderated content feed for the report endpoint, pagination, and the hosted-widget alternative if you'd rather not write this yourself.