Most "contact us" forms on freelancer and agency sites do the same thing. A visitor types a message, hits send, and the message lands somewhere in an inbox. There is no structure to it. You cannot tell what service someone wants, what their budget looks like, or how urgent the project is until you open the email and read it line by line.
A quote request system fixes that. Instead of one open text box, you ask the questions that actually help you price and prioritize work: what kind of project this is, what the scope looks like, what budget range they have in mind, and how soon they need it done. Every submission arrives already sorted.
In this tutorial, we will build a complete quote request system in React. Not just a form, a full pipeline: a form component that collects structured project details, client-side validation, a submit handler that posts to a backend, an instant email notification, and a lead management view where we can track every request from New to Contacted to Converted.
Here is the architecture we are building:
Form Component -> Validation -> Submit -> Backend API -> Email Notification -> Lead Management
The frontend is plain React. For the backend, email notification, and lead management pieces, you will use Formgrid, an open-source form backend, so you can focus this tutorial on the part that actually needs your attention: building a form that captures the right information and converts.
What We Are Building
A multi-field quote request form in React with:
- Full name, email, company, and phone fields
- A radio group for the type of work being quoted
- A textarea for project scope
- Radio groups for budget range and timeline
- Client-side validation before anything gets submitted
- A loading state while the request is in flight
- A success state once the request lands
On the backend side, every submission becomes:
- An instant email notification to your inbox
- A row in your Formgrid submissions dashboard
- A tracked lead with a status of New, which you can move to Contacted or Converted, add notes to, and set a follow-up reminder on
No server code. No database. No separate CRM.
The full code for this tutorial is available on GitHub if you want to clone it and follow along, or just skip ahead and run it directly: github.com/allenarduino/react-quote-request-system.
Prerequisites
Before starting, make sure you have:
- Node.js 18 or higher installed
- Basic familiarity with React and hooks
- A free Formgrid account (you will create one during this tutorial)
Step 1: Set Up the React Project
Create a new React project using Vite:
npm create vite@latest quote-request-system -- --template react
cd quote-request-system
npm install
npm run dev
Open the local dev server URL in your browser and confirm the default page loads.
Step 2: Create Your Formgrid Form
Set up your Formgrid form before writing any component code, so you have the endpoint URL ready to drop in later.
Go to formgrid.dev and sign up using Google or email. No credit card required.
Once you are logged in, click New Form from your dashboard and name it something like "Quote Requests." Click Create Form.
You will land on the form's Overview tab. Copy your endpoint URL. It looks like this:
https://formgrid.dev/api/f/your-form-id
Save this URL. You will use it in your submit handler in Step 5.
If you would rather start from a template than build the fields from scratch, Formgrid also has a Quote Request template with this exact field set already built, including the multi-step layout. You can open it in the anonymous builder at
Quote Request Form Nocode Template and customize it without creating an account first. You can later embed it in your website.
This tutorial builds the form by hand in React so you understand every piece, but the template is there if you want a shortcut for the Formgrid side of things.
Step 3: Build the Project Structure
Inside src, create a components folder and a QuoteRequestForm.jsx file inside it:
src/
components/
QuoteRequestForm.jsx
QuoteRequestForm.css
App.jsx
Open App.jsx and replace its contents with:
import { useMemo, useState } from 'react';
import './QuoteRequestForm.css';
const FORMGRID_ENDPOINT = 'https://formgrid.dev/api/f/brcy3qd4';
const HERO_IMAGE =
'https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=1400&q=80';
const initialState = {
company: '',
fullName: '',
email: '',
phone: '',
quoteFor: '',
budget: '',
timeline: '',
projectScope: '',
};
const quoteForOptions = [
'Web design and development',
'Branding and creative',
'Marketing and advertising',
'Construction or renovation',
'Consulting or professional services',
'Other',
];
const budgetOptions = [
'Under $5,000',
'$5,000 – $15,000',
'$15,000 – $50,000',
'$50,000+',
'Not sure yet',
];
const timelineOptions = [
'ASAP — within 2 weeks',
'Within 1 month',
'Within 3 months',
'Flexible: No fixed deadline',
];
const requiredFields = ['fullName', 'email', 'quoteFor', 'budget', 'projectScope'];
export default function QuoteRequestForm() {
const [formData, setFormData] = useState(initialState);
const [errors, setErrors] = useState({});
const [status, setStatus] = useState('idle');
const filledCount = useMemo(
() => requiredFields.filter((key) => formData[key].trim() !== '').length,
[formData],
);
const progress = Math.round((filledCount / requiredFields.length) * 100);
function handleChange(e) {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: undefined }));
}
}
function validate() {
const nextErrors = {};
if (!formData.fullName.trim()) nextErrors.fullName = 'Please enter your name';
if (!formData.email.trim()) {
nextErrors.email = 'Email is required';
} else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
nextErrors.email = 'Enter a valid email address';
}
if (!formData.quoteFor) nextErrors.quoteFor = 'Select what you need';
if (!formData.budget) nextErrors.budget = 'Select an estimated budget';
if (!formData.projectScope.trim()) nextErrors.projectScope = 'Tell us about your project';
setErrors(nextErrors);
return Object.keys(nextErrors).length === 0;
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setStatus('sending');
try {
const res = await fetch(FORMGRID_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ formData }),
});
if (res.ok) {
setStatus('success');
setFormData(initialState);
} else {
setStatus('error');
}
} catch {
setStatus('error');
}
}
if (status === 'success') {
return (
<div className="qf-card qf-success">
<div className="qf-success__badge" aria-hidden="true">
<svg viewBox="0 0 24 24" width="34" height="34" fill="none">
<path
d="m5 13 4 4L19 7"
stroke="currentColor"
strokeWidth="2.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<h2>Request received</h2>
<p>
Thanks! We've got your details and will send a tailored quote within 2
business days.
</p>
<button
type="button"
className="qf-btn qf-btn--ghost"
onClick={() => setStatus('idle')}
>
Submit another request
</button>
</div>
);
}
return (
<div className="qf-card">
<header className="qf-topbar">
<div>
<p className="qf-topbar__title">Quote Request Form</p>
<p className="qf-topbar__hint">
Fill in all required fields marked with <span className="qf-req">*</span>
</p>
</div>
<div className="qf-progress" aria-hidden="true">
<span className="qf-progress__label">
{filledCount} of {requiredFields.length} required fields filled
</span>
<span className="qf-progress__track">
<span className="qf-progress__bar" style={{ width: `${progress}%` }} />
</span>
</div>
</header>
<div className="qf-hero">
<h1>Request a quote</h1>
<p>
Tell us about your project and we'll send a tailored quote within 2 business
days. No commitment required.
</p>
<div className="qf-hero__image">
<img src={HERO_IMAGE} alt="Two colleagues collaborating at a desk" />
</div>
</div>
<form className="qf-form" onSubmit={handleSubmit} noValidate>
<section className="qf-section">
<h2 className="qf-section__title">Contact details</h2>
<div className="qf-grid">
<div className="qf-field">
<label htmlFor="company">Company</label>
<input
id="company"
name="company"
type="text"
placeholder="Acme Inc."
value={formData.company}
onChange={handleChange}
/>
</div>
<div className="qf-field">
<label htmlFor="fullName">
Your name <span className="qf-req">*</span>
</label>
<input
id="fullName"
name="fullName"
type="text"
placeholder="Jane Smith"
aria-invalid={Boolean(errors.fullName)}
value={formData.fullName}
onChange={handleChange}
/>
{errors.fullName && <span className="qf-error">{errors.fullName}</span>}
</div>
<div className="qf-field">
<label htmlFor="email">
Email <span className="qf-req">*</span>
</label>
<input
id="email"
name="email"
type="email"
placeholder="you@company.com"
aria-invalid={Boolean(errors.email)}
value={formData.email}
onChange={handleChange}
/>
{errors.email && <span className="qf-error">{errors.email}</span>}
</div>
<div className="qf-field">
<label htmlFor="phone">Phone</label>
<input
id="phone"
name="phone"
type="tel"
placeholder="+1 (555) 000-0000"
value={formData.phone}
onChange={handleChange}
/>
</div>
</div>
</section>
<div className="qf-divider" />
<section className="qf-section">
<h2 className="qf-section__title">Project details</h2>
<div className="qf-field">
<label htmlFor="quoteFor">
What do you need? <span className="qf-req">*</span>
</label>
<div className="qf-select">
<select
id="quoteFor"
name="quoteFor"
aria-invalid={Boolean(errors.quoteFor)}
value={formData.quoteFor}
onChange={handleChange}
>
<option value="" disabled>
Select…
</option>
{quoteForOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<svg
className="qf-select__chevron"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
>
<path
d="m6 9 6 6 6-6"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
{errors.quoteFor && <span className="qf-error">{errors.quoteFor}</span>}
</div>
<fieldset className="qf-field qf-fieldset">
<legend>
Estimated budget <span className="qf-req">*</span>
</legend>
<div className="qf-radios">
{budgetOptions.map((option) => (
<label
key={option}
className={`qf-radio ${formData.budget === option ? 'is-selected' : ''
}`}
>
<input
type="radio"
name="budget"
value={option}
checked={formData.budget === option}
onChange={handleChange}
/>
<span>{option}</span>
</label>
))}
</div>
{errors.budget && <span className="qf-error">{errors.budget}</span>}
</fieldset>
<div className="qf-field">
<label htmlFor="timeline">When do you need to start?</label>
<div className="qf-select">
<select
id="timeline"
name="timeline"
value={formData.timeline}
onChange={handleChange}
>
<option value="" disabled>
Select…
</option>
{timelineOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
<svg
className="qf-select__chevron"
viewBox="0 0 24 24"
width="18"
height="18"
aria-hidden="true"
>
<path
d="m6 9 6 6 6-6"
stroke="currentColor"
strokeWidth="2"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</div>
<div className="qf-field">
<label htmlFor="projectScope">
Describe your project <span className="qf-req">*</span>
</label>
<textarea
id="projectScope"
name="projectScope"
rows={5}
placeholder="Share goals, deliverables, timeline, references, or links to inspiration…"
aria-invalid={Boolean(errors.projectScope)}
value={formData.projectScope}
onChange={handleChange}
/>
{errors.projectScope && (
<span className="qf-error">{errors.projectScope}</span>
)}
</div>
</section>
{status === 'error' && (
<p className="qf-alert" role="alert">
Something went wrong while sending your request. Please try again.
</p>
)}
<button type="submit" className="qf-btn" disabled={status === 'sending'}>
{status === 'sending' ? 'Sending…' : 'Submit quote request'}
</button>
</form>
</div>
);
}
Replace FORMGRID_ENDPOINT with the endpoint URL you copied in Step 2.
Step 5: Style the Form
Create src/components/QuoteRequestForm.css:
.qf-card {
width: 100%;
max-width: 860px;
margin: 0 auto;
background: var(--app-bg);
border-radius: 18px;
overflow: hidden;
}
/* Top bar */
.qf-topbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 18px 28px;
border-bottom: 1px solid var(--border);
background: transparent;
}
.qf-topbar__title {
font-size: 14px;
font-weight: 700;
color: var(--text-h);
letter-spacing: 0.1px;
}
.qf-topbar__hint {
font-size: 12.5px;
color: var(--text-muted);
margin-top: 2px;
}
.qf-progress {
text-align: right;
min-width: 190px;
}
.qf-progress__label {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.qf-progress__track {
display: block;
height: 6px;
border-radius: 999px;
background: #eef1f6;
overflow: hidden;
}
.qf-progress__bar {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), #4f8bff);
transition: width 0.35s ease;
}
/* Hero */
.qf-hero {
padding: 34px 28px 8px;
}
.qf-hero h1 {
font-family: var(--heading);
font-size: 32px;
line-height: 1.15;
font-weight: 800;
letter-spacing: -0.6px;
color: var(--text-h);
margin: 0 0 10px;
}
.qf-hero p {
font-size: 15px;
line-height: 1.55;
color: var(--text);
max-width: 54ch;
}
.qf-hero__image {
margin-top: 22px;
border-radius: 14px;
overflow: hidden;
aspect-ratio: 16 / 8;
background: #eef1f6;
}
.qf-hero__image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Form layout */
.qf-form {
padding: 26px 28px 30px;
}
.qf-section + .qf-divider {
margin: 26px 0;
}
.qf-divider {
height: 1px;
background: var(--border);
}
.qf-section__title {
font-family: var(--heading);
font-size: 18px;
font-weight: 700;
letter-spacing: -0.2px;
color: var(--text-h);
margin: 0 0 18px;
}
.qf-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.qf-field {
display: flex;
flex-direction: column;
gap: 7px;
margin-bottom: 18px;
min-width: 0;
}
.qf-grid .qf-field {
margin-bottom: 0;
}
.qf-field:last-child {
margin-bottom: 0;
}
label,
.qf-fieldset legend {
font-size: 13.5px;
font-weight: 600;
color: var(--text-h);
padding: 0;
}
.qf-req {
color: var(--danger);
font-weight: 700;
}
/* Inputs */
.qf-field input[type='text'],
.qf-field input[type='email'],
.qf-field input[type='tel'],
.qf-field textarea,
.qf-select select {
width: 100%;
padding: 11px 13px;
font-family: inherit;
font-size: 14px;
color: var(--text-h);
background: var(--field-bg);
border: 1px solid var(--border-strong);
border-radius: 10px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
appearance: none;
}
.qf-field textarea {
resize: vertical;
min-height: 120px;
line-height: 1.5;
}
.qf-field input::placeholder,
.qf-field textarea::placeholder {
color: var(--text-muted);
}
.qf-field input:hover,
.qf-field textarea:hover,
.qf-select select:hover {
border-color: #b6bfcd;
}
.qf-field input:focus,
.qf-field textarea:focus,
.qf-select select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 4px var(--ring);
}
.qf-field input[aria-invalid='true'],
.qf-field textarea[aria-invalid='true'],
.qf-select select[aria-invalid='true'] {
border-color: var(--danger);
}
/* Select */
.qf-select {
position: relative;
}
.qf-select select {
padding-right: 40px;
cursor: pointer;
}
.qf-select__chevron {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
/* Radios */
.qf-fieldset {
border: none;
margin: 0 0 18px;
padding: 0;
}
.qf-fieldset legend {
margin-bottom: 10px;
}
.qf-radios {
display: grid;
gap: 8px;
}
.qf-radio {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 13px;
border: 1px solid var(--border-strong);
border-radius: 10px;
font-size: 14px;
font-weight: 500;
color: var(--text-h);
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
}
.qf-radio:hover {
border-color: #b6bfcd;
}
.qf-radio input {
accent-color: var(--accent);
width: 16px;
height: 16px;
margin: 0;
}
.qf-radio.is-selected {
border-color: var(--accent);
background: var(--accent-soft);
box-shadow: 0 0 0 1px var(--accent) inset;
}
/* Note + alerts */
.qf-note {
display: flex;
align-items: center;
gap: 8px;
margin: 22px 0 20px;
font-size: 13px;
color: var(--text-muted);
}
.qf-note svg {
color: var(--success);
flex-shrink: 0;
}
.qf-alert {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 10px;
background: rgba(220, 38, 38, 0.08);
border: 1px solid rgba(220, 38, 38, 0.25);
color: var(--danger);
font-size: 13.5px;
font-weight: 500;
}
.qf-error {
color: var(--danger);
font-size: 12.5px;
font-weight: 500;
}
/* Button */
.qf-btn {
width: 100%;
padding: 14px 20px;
border: none;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.1px;
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease, box-shadow 0.15s ease;
box-shadow: 0 8px 20px -8px rgba(31, 42, 68, 0.55);
}
.qf-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.qf-btn:active:not(:disabled) {
transform: translateY(1px);
}
.qf-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px var(--ring);
}
.qf-btn:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.qf-btn--ghost {
background: transparent;
color: var(--text-h);
border: 1px solid var(--border-strong);
box-shadow: none;
width: auto;
padding: 11px 20px;
margin-top: 20px;
}
.qf-btn--ghost:hover:not(:disabled) {
background: #f4f6fa;
}
/* Success */
.qf-success {
padding: 48px 32px 40px;
text-align: center;
}
.qf-success__badge {
width: 64px;
height: 64px;
margin: 0 auto 18px;
border-radius: 50%;
display: grid;
place-items: center;
background: rgba(22, 163, 74, 0.12);
color: var(--success);
}
.qf-success h2 {
font-family: var(--heading);
font-size: 24px;
font-weight: 700;
color: var(--text-h);
margin: 0 0 8px;
}
.qf-success p {
font-size: 15px;
color: var(--text);
max-width: 46ch;
margin: 0 auto;
line-height: 1.55;
}
/* Responsive */
@media (max-width: 640px) {
.qf-topbar {
flex-direction: column;
padding: 16px 18px;
}
.qf-progress {
text-align: left;
width: 100%;
min-width: 0;
}
.qf-hero,
.qf-form {
padding-left: 18px;
padding-right: 18px;
}
.qf-hero h1 {
font-size: 26px;
}
.qf-grid {
grid-template-columns: 1fr;
}
}
At this point, run npm run dev and fill out the form. Submitting it sends a POST request straight to your Formgrid endpoint.
Step 6: Confirm the Submission Arrives
Fill in the form with real-looking test data and click Request Quote.
Within seconds, three things happen.
First, an email notification arrives at the address you signed up with, containing every field from the submission formatted cleanly.
Second, the same submission appears in your Leads tab with a status of New. This is the part a plain email notification cannot give you.
If both show up, your quote request system is working end to end.
Step 7: Work the Lead, Not Just the Submission
Open the lead you just created in your dashboard.
Change its status from New to Contacted once you have reached out. When the project actually closes, move it to Converted. Your conversion rate updates automatically as you work through leads, so over time you know what fraction of quote requests turn into paying work, not just how many people filled in the form.
Add a private note after every call. What they asked for, what you quoted, what the next step is.
Spoke on the phone Tuesday. Wants the full 6- to 8-page site plus blog. Quoted $9,500.
Waiting on her to confirm the October trade show date before we lock the timeline.
Set a follow-up reminder for a date in the future. Formgrid emails you on that date with the lead's details and your notes attached, so you never lose track of a quote you sent out.
This is the part that separates a form that just collects submissions from a system that actually helps you close work. A quote request that goes cold because nobody followed up is a lost project. A tracked lead with a reminder attached is not.
Step 8: Add Spam Protection
Before this goes live, turn on spam protection from your form settings.
Go to your form's Settings tab and scroll to Security Settings. You can enable a honeypot field, CAPTCHA, rate limiting, or restrict submissions to your own domain.
If you would rather add a honeypot manually in your React form, add a hidden input and check it before sending the request:
<input
type="text"
name="_honey"
style={{ display: 'none' }}
tabIndex={-1}
autoComplete="off"
/>
Then in handleSubmit, bail out early if it has a value, since a real visitor will never fill in a field they cannot see.
Step 9: Sync to Google Sheets
If you want your whole team to see quote requests without logging into Formgrid, connect Google Sheets from your form's Integrations tab.
Every submission from this point on adds a new row automatically, with columns that match your form fields exactly. No Zapier, no manual export.
Why Not Just Use a Plain Contact Form
A single message box is faster to build, but it pushes all the sorting work onto you after the fact. You have to read every message to figure out what someone actually wants, guess at their budget, and manually track who you have followed up with, usually in your own head or a scattered set of email threads.
A structured quote request form does that sorting up front. By the time a request lands in your inbox, you already know the service, the scope, the budget range, and the timeline. Paired with a lead pipeline, you also know exactly where every request stands, instead of losing track of the ones that went quiet.
Deploying
Build the project for production:
npm run build
Deploy the dist folder to any static host; Vercel, Netlify, or Cloudflare Pages all work with zero configuration for a Vite project. Your form will keep working identically in production, since Formgrid handles the backend processing regardless of where the frontend is hosted.
Final Thoughts
A quote request form is a small amount of extra structure that pays off every time someone submits one. You spend less time reading and guessing, and more time responding with an actual number, because the information you need was collected upfront.
The React side of this tutorial is a form component, a validation function, and a fetch call. The part that turns it into a system, tracking who you have contacted, who converted, and who needs a follow-up, comes from Formgrid running underneath it.
If you want to build this for your own site, start free at formgrid.dev. The form backend, email notifications, and lead pipeline are all included from day one.


















Top comments (0)