How to build a contact form in React Native (and Expo) that sends email
A React Native contact form that emails you every message, with a sending state, bot protection and no backend. Works in Expo and bare React Native.
Updated:
You’ll add a contact form to a React Native or Expo app with the @frontmail/react-native
package: a provider, a useSendEmail hook that tracks the sending state, and a ready-made
Turnstile component against bots. The email goes out through your own mailbox.
You need: a free Frontmail account, an Expo or React Native project and about ten minutes.
1. Connect your mailbox and create the template
- Email services → Add service: connect Gmail, Outlook, SMTP or a transactional provider, tick Make default, Verify & save.
- Templates → From gallery → Contact form → Use template. Required parameters:
name,email,message; optional:phone,topic. - Copy the template ID (
tpl_…) from the Snippets tab.
2. Allow mobile apps
- Security → Mobile apps → Allow mobile apps → Save. Apps send no
Originheader, so without this switch the API answersorigin_not_allowed. - Template → Settings → Protection → Require CAPTCHA (Turnstile). The app uses Frontmail’s shared Turnstile widget; you don’t need Cloudflare keys.
3. Install
npx expo install @frontmail/react-native @react-native-async-storage/async-storage react-native-webview
In a bare React Native project use npm i with the same packages and run npx pod-install.
4. Build the form
import { useRef, useState } from 'react';
import { Button, Text, TextInput, View } from 'react-native';
import { FrontmailProvider, TurnstileWebView, useSendEmail, type TurnstileWebViewHandle } from '@frontmail/react-native';
const options = { publicKey: 'pk_…' }; // keep the object stable
export default function App() {
return (
<FrontmailProvider options={options}>
<ContactForm />
</FrontmailProvider>
);
}
function ContactForm() {
const { send, status, error } = useSendEmail(null, 'tpl_…'); // null = default service
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [token, setToken] = useState<string>();
const turnstile = useRef<TurnstileWebViewHandle>(null);
const onSend = async () => {
await send({ name, email, message }, { turnstileToken: token });
setToken(undefined);
turnstile.current?.reset(); // a token works only once
};
if (status === 'sent' || status === 'held') return <Text>Thank you! We’ll get back to you soon.</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" />
<TextInput placeholder="Message" value={message} onChangeText={setMessage} multiline />
<TurnstileWebView ref={turnstile} onToken={setToken} />
<Button title="Send" disabled={!token || status === 'sending'} onPress={onSend} />
{error && <Text>{error.message}</Text>}
</View>
);
}
send never throws: it resolves with the result or undefined, and status moves from idle to
sending and then sent, held or error. Retries after network errors reuse one idempotency
key, so a user on a train never sends you the same message twice.
5. Test it
Start the app (npx expo start), fill in the form and tap Send. The email lands in your inbox
and in History in the dashboard.
origin_not_allowed– Allow mobile apps is off or not saved.captcha_required– the Turnstile check hasn’t finished yet; the button stays disabled until it has a token.invalid_template_params– a required field is empty or the email is invalid.
heldmeans the message was accepted while your credits were used up. It goes out automatically as soon as you top up – nothing is dropped.
Next steps
- Add
limitRate: { id: 'contact', throttle: 30_000 }to the provider options to allow one message per 30 seconds per device. - Send a confirmation to the user with the Auto-reply template from the gallery.


