Basic Bootstrap contact form markup
Bootstrap has no contact form component. What it has is a set of form classes, and a contact form comes down to a few fields, some of those classes and a submit button.
The Bootstrap 5.3 documentation says to give every input a proper type (email for addresses, for example) so the browser’s own input checks apply.
Fields and input types
Name is a plain type=”text” input and it’s required. Email uses type=”email”, also required, and every reply depends on it. Subject is optional, and I’d drop it on short forms. The message box is a textarea with rows=”5″, required too.
Every field also needs a name attribute. Leave it off and the browser drops that field from the submitted data.
Classes that style the fields
Most of the styling work comes from a short list.
- form-control goes on every input and textarea
- Each label gets form-label, which Bootstrap 5 requires
- The div wrapping a label and its field takes mb-3
- The submit button gets btn btn-primary
- row and col-* handle the outer layout
Bootstrap’s form styles build on its Reboot stylesheet, which is why the same markup renders alike across browsers.
Copy-paste markup
Load the Bootstrap 5.3 stylesheet first. The rest is plain HTML.
<div class="container my-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<form action="/contact" method="POST">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="subject" class="form-label">Subject</label>
<input type="text" class="form-control" id="subject" name="subject">
</div>
<div class="mb-3">
<label for="message" class="form-label">Message</label>
<textarea class="form-control" id="message" name="message" rows="5" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Send message</button>
</form>
</div>
</div>
</div>
The action URL is a placeholder until a server script or hosted endpoint receives the request.
How to lay out the form with the grid or floating labels
Stacked or two columns covers most contact forms, and floating labels suit the short ones. It mostly depends on how much horizontal room the form gets.
Bootstrap’s CSS is mobile first, so the smallest layout gets the base styles and larger breakpoints add to them. The md breakpoint starts at 768px, and col-md-* columns stack below it (Bootstrap 5.3 breakpoints documentation).
Stacked layout
This is the default. Each label and field sits in its own mb-3 wrapper at full width, which works fine in a sidebar, a modal or a narrow column.
Two-column grid
Wrap the fields in a row with the g-3 gutter class, then give name and email col-md-6 each. Subject and message take col-12.
Bootstrap’s grid system uses min-width breakpoints, so the pair sits side by side at 768px and up and collapses to one column on phones.
Floating labels
Floating labels come with a few quirks the documentation spells out. Every input needs a non-empty placeholder, since the CSS-only label relies on :placeholder-shown to detect an empty field. The input also comes before the label in the markup, and a custom textarea height comes from an explicit height style rather than the rows attribute.
Wrap each input and label pair in form-floating. When floating labels sit inside the grid, the form-floating wrappers go inside the column classes.
I’d keep them to forms with three or four fields. Long labels get cramped, and a stacked layout wins there.
What changes between Bootstrap 4 and Bootstrap 5 contact forms?
Bootstrap 5 drops jQuery and the form-group wrapper, requires form-label on labels, and supports floating labels as a real component.
| Feature | Bootstrap 4 | Bootstrap 5 |
|---|
| Field wrapper | .form-group | mb-3 spacing utility |
| Label class | Not required | .form-label required |
| Floating labels | Example only | .form-floating component |
| jQuery | Dependency | Dropped |
The Bootstrap 5 migration notes (as of release 5.3.8) list form-group, form-row and form-inline as dropped in favor of the grid and utilities.
Older tutorials are the biggest trap. The Tuts+ contact form guide, for one, is built on Bootstrap 3 with a jQuery Ajax call, and that code needs a rewrite before it fits a Bootstrap 5 page.
MDB has moved on too, and its old Bootstrap 4 (MDB4) documentation URLs now redirect to the MDB5 docs. A site that already runs v4 with jQuery can keep its form as it is.
Porting a Bootstrap 4 snippet
Porting comes down to swapping form-group for mb-3 and adding form-label to the labels. Any $.ajax() calls get rewritten with fetch.
Skip the first swap and the spacing between fields disappears, because Bootstrap 5 has no form-group class to supply it.
How to add client-side validation with needs-validation and novalidate
In short, the form gets the needs-validation class and the novalidate attribute, the fields get required, and each one gets an invalid-feedback message underneath. A submit listener then calls checkValidity() and adds was-validated. Bootstrap’s CSS handles the visual states.
- Add class=”needs-validation” and novalidate to the form tag.
- Set required on name, email and message, and keep type=”email” on the address field.
- Add a div with class invalid-feedback after each control, with a plain-language message.
- Attach a submit listener that stops submission when checkValidity() returns false.
- Add was-validated to the form on every submit attempt.
- Test an empty submit, a malformed email and a valid submit.
The listener is a dozen lines of JavaScript, and the Bootstrap docs supply a starter version.
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
<div class="invalid-feedback">Enter a valid email address.</div>
</div>
(() => {
'use strict'
const forms = document.querySelectorAll('.needs-validation')
Array.from(forms).forEach(form => {
form.addEventListener('submit', event => {
if (!form.checkValidity()) {
event.preventDefault()
event.stopPropagation()
}
form.classList.add('was-validated')
})
})
})()
What each piece does
novalidate turns off the browser’s default tooltips but keeps the validation APIs available in JavaScript (Bootstrap 5.3 documentation). was-validated scopes the :valid and :invalid styles, so empty required fields don’t show as errors on page load. And invalid-feedback is just the message that appears under a failing field.
Custom feedback styles carry an accessibility caveat, which the next section covers.
Where client-side validation stops
Any direct POST request skips the browser checks entirely. MDN Web Docs states that HTML constraint validation does not remove the need for server-side validation.
Bootstrap supports that route too. The .is-invalid and .is-valid classes mark fields from server results and work without a was-validated parent.
Are Bootstrap validation messages accessible to screen readers?
Not by default. The Bootstrap 5.3 documentation states that client-side custom validation styles and tooltips are not exposed to assistive technologies, and it points to the server-side option or the browser default method as the safe choices for now.
Other documentation takes the opposite view, so the sources need a closer look before you pick a pattern.
Where the sources disagree
The Bootstrap 5.3 documentation says custom styles and tooltips are invisible to assistive technologies, so it recommends server-side classes or browser defaults. MDB’s validation documentation argues the reverse. It recommends custom validation styles, because native browser validation messages are not consistently exposed to assistive technologies in all browsers, most notably Chrome.
Both describe a real gap. Custom styles are visual only unless markup connects them to the field, and Bootstrap’s own docs note that browser default messages differ per browser and cannot be styled with CSS.
A pattern that holds under both readings
Keep the Bootstrap classes for styling and add the announcement layer yourself. A few ARIA attributes and a live region cover what the styles lack.
Point each input at the id of its invalid-feedback element with aria-describedby, the same wiring Bootstrap’s server-side example uses. Put the post-submit message in a live region with role=”status” so it is announced without moving focus. On a failed submit, send focus to the first invalid field.
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required aria-describedby="emailError">
<div id="emailError" class="invalid-feedback">Enter a valid email address.</div>
</div>
<div id="formStatus" role="status"></div>
// inside the submit listener, after was-validated is added
if (!form.checkValidity()) {
form.querySelector(':invalid').focus()
}
For a wider checklist covering labels, grouping and error text, this guide to accessible forms goes deeper.
What WCAG 2.2 asks for
Criterion 3.3.1 (Level A) requires that an input error is identified and described in text, so color alone fails. Criterion 4.1.3 (Level AA) covers status messages such as “Message sent”, which must be exposed through a role or property without taking focus.
An error or success message written into the page with no live region or role stays silent for screen reader users. W3C technique F103 lists exactly that pattern as a failure of 4.1.3.
Bootstrap’s forms documentation also says a visible label is generally the best approach for accessibility and usability. Placeholder-only fields are the weak option.
How to send Bootstrap contact form data to email
Match the method to the hosting. Static sites post to a hosted endpoint. On PHP hosting you’d run PHPMailer over SMTP, and Node.js apps tend to use Nodemailer.
| Method | Own server needed | Setup effort | Main failure case |
|---|
| PHP mail() | Yes, PHP host | Low | Accepted, never delivered |
| PHPMailer over SMTP | Yes, PHP host | Medium | Wrong SMTP credentials or port |
| Nodemailer | Yes, Node.js | Medium | Sender IP without reputation |
| Hosted endpoint | No | Low | Monthly submission cap |
| EmailJS | No | Low | Monthly request cap |
PHP with mail() and PHPMailer
PHP’s built-in mail() is the shortest path and the weakest one. The PHP manual itself warns that a message accepted for delivery has not necessarily reached the recipient.
PHPMailer’s README goes further and says mail() should be avoided when possible, since SMTP to localhost is faster and safer.
On the plus side, mail() is one function call, needs no library and exists on most PHP hosts. The catch is no delivery confirmation, a required From header (PHP raises a warning without it) and a lot of guesswork when something fails.
PHPMailer brings SMTP authentication, TLS on port 587 (STARTTLS) or 465 (SMTPS) and exceptions with a readable error message. You do need Composer or manual includes, plus an SMTP account whose credentials you have to store somewhere.
Send from an address on your own domain and set the visitor’s address as Reply-To. That is the usual setup for a contact form.
Node.js with Nodemailer
Nodemailer is the default choice when the form handler already runs on Node.js.
The documentation lists Node.js 20 or later as the requirement for Nodemailer 10, and says to stay on the 9.x line for older Node.js versions. Its SMTP example uses port 587 with STARTTLS and reads credentials from environment variables, not from source code.
It has zero runtime dependencies and supports SMTP, Sendmail and Amazon SES transports, plus test accounts through Ethereal. The downside is the running Node.js server. Mail sent straight from a fresh VPS address often gets rejected or lands in spam, so relay through an authenticated SMTP service.
Hosted endpoints and EmailJS
A hosted endpoint replaces the backend script entirely. You point the form action at the vendor’s URL and the vendor emails you the data.
Hosted form endpoints (Formspree, Netlify Forms, Web3Forms)
These need no server, work on static hosting and take minutes to set up. Free plans cap submissions per month, though, a third party stores your messages, and prices change.
Caps differ by vendor and shift over time, so read the vendor’s current pricing page before committing.
EmailJS
EmailJS sends mail from client-side JavaScript through templates you set up in its dashboard. There’s no server code to write, and the template editor is handy. The free plan is limited to a small number of requests per month, and the integration lives in page source.
Submitting with fetch and showing a status message
The form can post without a page reload. The request goes to an API endpoint, which is either your own script or a hosted URL.
Replace the earlier validation listener with one that validates, then posts. The status region is the same live region from the accessibility section.
form.addEventListener('submit', async event => {
event.preventDefault()
form.classList.add('was-validated')
if (!form.checkValidity()) return
const status = document.getElementById('formStatus')
try {
const response = await fetch(form.action, {
method: 'POST',
body: new FormData(form)
})
if (!response.ok) throw new Error('Request failed')
status.textContent = 'Message sent. We will reply by email.'
} catch (error) {
status.textContent = 'Something went wrong. Try again or email us directly.'
}
})
preventDefault runs on every submit because fetch now owns the request, and response.ok separates a server error from a network failure.
A FormData body is sent as multipart, so check which content type the endpoint accepts.
How to stop spam on a Bootstrap contact form
Start with a honeypot field plus server-side validation. Add a CAPTCHA only after spam gets through, and verify its token on the server, because a widget alone protects nothing.
Cloudflare’s Turnstile documentation says this outright. The client-side widget does not protect a form until the server calls the siteverify endpoint.
Honeypot field
A honeypot is a field real visitors never see and simple bots fill in.
Hide it with CSS, add tabindex=”-1″ and aria-hidden=”true” so keyboard and screen reader users skip it, and discard any submission where it has a value. Never mark it required, or the validation listener blocks every real submit.
<div class="visually-hidden" aria-hidden="true">
<label for="website">Leave this field empty</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
</div>
Visitors never notice it, and there’s no third-party script or keys to manage. Targeted bots that parse the markup can still skip the trap.
Google reCAPTCHA
Each reCAPTCHA response token is valid for two minutes and can be verified once, according to Google’s documentation.
Version 3 returns a score from 0.0 to 1.0 with no visible challenge, and Google suggests 0.5 as the default threshold. The server also checks the action name returned with the score.
Score-based v3 adds no puzzle for the visitor, and libraries exist for most server languages. On the downside, it needs a server script to call siteverify, so a static site needs a hosted endpoint or function. A visitor who writes a long message can also submit an expired token and get a timeout-or-duplicate error.
hCaptcha
hCaptcha follows the same shape as reCAPTCHA. The widget writes a token into the h-captcha-response field, and the server verifies it at hCaptcha’s siteverify endpoint.
Tokens are single-use, and a new one is needed after each submission, expiration or error, per hCaptcha’s documentation. The upside is an easy swap from reCAPTCHA, since mostly the field name and endpoint change. The downside is a visible challenge for visitors.
Cloudflare Turnstile
Turnstile tokens stay valid for 300 seconds and are single use (Cloudflare Turnstile documentation, as of October 2026). The server POSTs the secret key and token to the siteverify API. If the token has expired, the API returns the timeout-or-duplicate error code, and turnstile.reset issues a fresh one. You do need a Cloudflare account.
The longer token window suits contact forms better than reCAPTCHA’s two minutes. The cost is the account and the extra setup.
Why is a Bootstrap contact form not sending email?
When a form won’t send, the cause is usually something mundane. These are the ones I’d check first.
- If the page reloads to an error or 404 on submit, the action path doesn’t exist or the host doesn’t run PHP. Open the URL directly and confirm the script responds.
- A button that does nothing usually means checkValidity() is failing on a field the visitor can’t see, such as a required honeypot, or a script error has stopped the listener. Check the browser console.
- Emails that arrive with empty values mean the script reads keys that differ from the field names, for example $_POST[‘userEmail’] against name=”email”.
- An error message that shows while the email still arrives points to a cross-origin endpoint that sends no CORS headers. The request reaches the server, but the browser blocks the response, so fetch throws.
- When the success message shows and nothing arrives, the mail server accepted the message and a later hop dropped it, or it’s sitting in spam.
- A CAPTCHA that rejects honest visitors has usually expired while they typed, which returns timeout-or-duplicate. Reset the widget and resubmit.
A PHPMailer issue report (#3012) documents the delivery case. Messages sent with mail() through the default sendmail path went out on PHP 7.4 but bounced on 8.0, 8.1, 8.2 and 8.3, and the reporter traced it to PHP 8 passing CRLF line endings to sendmail. Switching to SMTP delivered them.
Test the endpoint on its own with the form’s data before changing the markup. If the request works there, the fault is in the page.
Bootstrap Contact Form FAQ
Can a contact form sit inside a Bootstrap modal?
Yes. Put the fields in the modal-body and open the modal with data-bs-toggle=”modal” and data-bs-target.
If the submit button lives in the modal-footer, give it a form attribute that matches the form’s id, or wrap both parts in the form tag.
How do I reset the form after a successful submit?
Call form.reset() to clear the values, then remove the was-validated class so the green and red states disappear.
Bootstrap’s documentation recommends removing that class after dynamic submissions made with Ajax.
How do I add a phone number or consent checkbox?
For the phone number, add an input with type=”tel” and the form-control class.
For consent, wrap a required form-check-input and its form-check-label in a form-check div. The invalid-feedback message works inside that wrapper too.
How do I add icons to the fields?
Validation icons are built in and appear on their own when a field turns valid or invalid.
For decorative icons, place a Bootstrap Icons SVG inside an input-group-text span next to the field. Add has-validation to that input group so the field corners render correctly in validation states.
Does a contact form need a CSRF token?
Usually not for an anonymous form. A CSRF token protects actions that ride on a logged-in session, and a public contact form has no session to hijack.
Add one when the form sits behind a login or the handler touches account data.
Where can I find free contact form templates?
Galleries such as Colorlib, uiCookies and FreeFrontend publish free designs. Check the Bootstrap version in the template’s stylesheet link first, because Bootstrap 4 markup needs porting before it fits a Bootstrap 5 page.
Templates ship front-end code only, so the form still needs a handler or endpoint.
What to Recheck Once a Bootstrap Contact Form Is Live
Once the form is live, the delivery path, the spam keys and the Bootstrap version are what need another look.
The order follows the cost of failure. A broken delivery path loses messages, a rotated key blocks honest visitors, and a Bootstrap release changes appearance.
- Send a real test message every month
- Update SMTP credentials and CAPTCHA keys whenever a provider rotates them
- Read the release notes before upgrading Bootstrap
It’s a few minutes of routine work, and in return you find a dead form before a visitor does.
The accessibility advice stands as of Bootstrap 5.3.8, checked October 29, 2026. Once the documentation drops its warning about custom validation styles, the extra aria-describedby wiring is worth revisiting.