Skip to content

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

  1. Email services → Add service: connect your mailbox or provider, Make default, Verify & save.
  2. Templates → From gallery → Quote request (category Business) → Use template.
  3. From gallery → Auto-reply: thanks for your message → Use template. Set defaults for company_name and response_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 held and sent once you top up – the hook reports status === '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 of send to use a language variant of the template.
All tutorials