How to build a quote request form in React Native
A React Native and Expo inquiry form with service, budget and deadline choices that emails you every request and confirms it to the customer automatically.
Updated:
This tutorial builds a quote request screen in React Native: the customer chooses a service and a budget, adds a deadline and a description, and you receive a structured email. An auto-reply confirms the request to the customer.
You need: a free Frontmail account, an Expo or React Native project and about fifteen minutes.
1. Connect your mailbox and create the templates
- Email services → Add service: connect your mailbox or provider, Make default, Verify & save.
- Templates → From gallery → Quote request (category Business) → Use template.
- From gallery → Auto-reply: thanks for your message → Use template. Set defaults for
company_nameandresponse_time, then choose it in the quote template under Settings → Delivery → Auto-reply template.
Parameters of the quote template: name, email, service, details (required) and phone,
company, budget, deadline (optional, deadline as YYYY-MM-DD). service and budget accept
only the values listed on the Parameters tab – use the same strings in the app.
2. Allow mobile apps and turn on the CAPTCHA
- Security → Mobile apps → Allow mobile apps → Save.
- Quote template → Settings → Protection → Require CAPTCHA (Turnstile) – the app uses Frontmail’s shared widget, no keys needed.
3. Install
npx expo install @frontmail/react-native @react-native-async-storage/async-storage react-native-webview
4. Build the form
To keep the example free of extra dependencies, the choices are simple chip buttons and the
deadline is typed as YYYY-MM-DD. Swap in your favourite picker components – only the values
you pass to send matter.
import { useRef, useState } from 'react';
import { Button, Pressable, Text, TextInput, View } from 'react-native';
import { FrontmailProvider, TurnstileWebView, useSendEmail, type TurnstileWebViewHandle } from '@frontmail/react-native';
const options = { publicKey: 'pk_…' };
const SERVICES = ['Website', 'E-shop', 'Mobile app', 'Consulting', 'Other'];
const BUDGETS = ['Under $5k', '$5k–20k', '$20k–50k', 'Over $50k'];
export default function App() {
return (
<FrontmailProvider options={options}>
<QuoteForm />
</FrontmailProvider>
);
}
function Choice({ values, value, onChange }: { values: string[]; value?: string; onChange(v?: string): void }) {
return (
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
{values.map((v) => (
<Pressable key={v} onPress={() => onChange(v === value ? undefined : v)} accessibilityState={{ selected: v === value }}>
<Text style={{ padding: 8, borderWidth: 1, borderRadius: 16, fontWeight: v === value ? '700' : '400' }}>{v}</Text>
</Pressable>
))}
</View>
);
}
function QuoteForm() {
const { send, status, error } = useSendEmail(null, 'tpl_…');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [service, setService] = useState<string | undefined>(SERVICES[0]);
const [budget, setBudget] = useState<string>();
const [deadline, setDeadline] = useState('');
const [details, setDetails] = useState('');
const [token, setToken] = useState<string>();
const turnstile = useRef<TurnstileWebViewHandle>(null);
const onSend = async () => {
await send({ name, email, service, budget, deadline, details }, { turnstileToken: token });
setToken(undefined);
turnstile.current?.reset(); // a token works only once
};
if (status === 'sent' || status === 'held') return <Text>Thanks! We’ve emailed you a confirmation.</Text>;
return (
<View style={{ gap: 12, padding: 16 }}>
<TextInput placeholder="Name" value={name} onChangeText={setName} />
<TextInput placeholder="Email" value={email} onChangeText={setEmail} keyboardType="email-address" autoCapitalize="none" />
<Text>What do you need?</Text>
<Choice values={SERVICES} value={service} onChange={setService} />
<Text>Budget (optional)</Text>
<Choice values={BUDGETS} value={budget} onChange={setBudget} />
<TextInput placeholder="Deadline (YYYY-MM-DD, optional)" value={deadline} onChangeText={setDeadline} />
<TextInput placeholder="Project details" value={details} onChangeText={setDetails} multiline />
<TurnstileWebView ref={turnstile} onToken={setToken} />
<Button title="Request a quote" disabled={!token || !service || status === 'sending'} onPress={onSend} />
{error && <Text>{error.message}</Text>}
</View>
);
}
Empty optional values (no budget, empty deadline) are left out of the email automatically.
5. Test it
Run the app and send a request with your own email. You get the request and the confirmation; both
show up in History. Validation errors come back as invalid_template_params with the field
that failed in error.details.
A request with a confirmation uses two credits. When credits run out, requests are accepted as
heldand sent once you top up – the hook reportsstatus === 'held'.
Next steps
- Keep a list of everyone who asked: turn on Contact collection in the template settings.
- Pass
{ locale: 'de' }as the second argument ofsendto use a language variant of the template.


