This post originally showed how to build a React Native chatbot on Google’s PaLM API. That API no longer exists: Google decommissioned the PaLM API for its AI services on August 15, 2024 and pointed developers to the Gemini API. The old tutorial also put the API key inside the app, which anyone can extract. This rewrite builds the same chatbot the right way: a React Native chat screen that talks to a small backend, and a backend that talks to Gemini.
What I tested: the backend type-checks and passes three tests with the Gemini client mocked (@google/genai 2.30, Express 5), and the React Native screen passes TypeScript, ESLint and two component tests in a React Native 0.87 project. I did not call the live Gemini API, because that needs a billed key; the request format follows Google’s documentation and the SDK’s types.
How It Fits Together
| Part | Job |
|---|---|
| React Native app | Shows the conversation, sends the message history to your backend, displays the reply |
| Your backend (Node.js + Express) | Holds the Gemini API key, validates input, adds the system prompt, calls Gemini, returns the reply |
| Gemini API | Generates the response |
Why not call Gemini straight from the app? Google’s API key guidance says keys should never be exposed client-side in production, because keys compiled into an app can be extracted, and recommends routing calls through a backend. A backend also lets you cap message sizes, rate-limit users, log usage and change models without shipping an app update.
Prerequisites
- A React Native app (the React Native docs recommend starting with Expo:
npx create-expo-app@latest). - Node.js 20 or newer for the backend.
- A Gemini API key from Google AI Studio (the successor to MakerSuite, which the old post used).
Step 1: Build the Backend
mkdir chat-proxy && cd chat-proxy
npm init -y
npm install @google/genai express
npm install -D typescript tsx @types/express @types/nodeThe app logic lives in a function that receives the Gemini client, which makes it easy to test with a fake one:
import express from 'express';
import type { GoogleGenAI } from '@google/genai';
export type ChatMessage = { role: 'user' | 'model'; text: string };
const MODEL = process.env.GEMINI_MODEL ?? 'gemini-3.8-flash';
const SYSTEM_PROMPT =
'You are a friendly support assistant for a mobile app. Answer briefly and say when you are unsure.';
const MAX_MESSAGES = 20;
const MAX_CHARS = 2000;
export function createApp(ai: Pick<GoogleGenAI, 'models'>) {
const app = express();
app.use(express.json({ limit: '50kb' }));
app.post('/chat', async (req, res) => {
const messages: unknown = req.body?.messages;
// Validate input before spending tokens on it.
if (
!Array.isArray(messages) ||
messages.length === 0 ||
messages.length > MAX_MESSAGES ||
!messages.every(
(m): m is ChatMessage =>
(m?.role === 'user' || m?.role === 'model') &&
typeof m?.text === 'string' &&
m.text.length > 0 &&
m.text.length <= MAX_CHARS,
) ||
messages[messages.length - 1].role !== 'user'
) {
res.status(400).json({ error: 'Invalid messages' });
return;
}
try {
const response = await ai.models.generateContent({
model: MODEL,
contents: messages.map(m => ({ role: m.role, parts: [{ text: m.text }] })),
config: { systemInstruction: SYSTEM_PROMPT, maxOutputTokens: 1024 },
});
res.json({ reply: response.text ?? '' });
} catch (err) {
console.error('Gemini request failed', err);
res.status(502).json({ error: 'The assistant is unavailable. Please try again.' });
}
});
return app;
}What it does:
- Validates input before spending tokens: up to 20 messages, each 1 to 2,000 characters, roles
userormodel, and the last one from the user. - Sends the whole conversation as
contents, with roles, so Gemini sees the context. The server stores nothing; the app owns the history. - Adds a system instruction on the server, so users cannot change it from the app.
- Hides errors. Gemini failures are logged on the server, and the app gets a generic 502 message.
The entry point creates the real client. The SDK reads GEMINI_API_KEY from the environment:
import { GoogleGenAI } from '@google/genai';
import { createApp } from './app.js';
// The SDK reads GEMINI_API_KEY from the environment. The key stays on the server.
const ai = new GoogleGenAI({});
const port = Number(process.env.PORT ?? 3000);
createApp(ai).listen(port, () => console.log(`Chat proxy listening on :${port}`));GEMINI_API_KEY=your-key npx tsx src/index.tsAbout the model: the code defaults to gemini-3.8-flash, which is among the models Google’s models page recommends for new projects as of October 2026, and reads GEMINI_MODEL so you can switch without code changes. Gemini models are retired regularly, so check that page and keep the model name in configuration. Google’s docs now also show an Interactions API (ai.interactions.create) that can keep conversation state on Google’s side; this tutorial uses the stateless generateContent call so the backend stays simple and stores no data.
Test the backend
These tests start the app on a random port with a fake Gemini client and check the happy path, input validation and error handling:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { AddressInfo } from 'node:net';
import { createApp } from '../src/app.js';
function start(generateContent: (args: any) => Promise<any>) {
const fakeAi = { models: { generateContent } } as any;
const server = createApp(fakeAi).listen(0);
const { port } = server.address() as AddressInfo;
return { url: `http://127.0.0.1:${port}/chat`, close: () => server.close() };
}
const post = (url: string, body: unknown) =>
fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
test('forwards the conversation and returns the reply', async () => {
let received: any;
const s = start(async args => { received = args; return { text: 'Hi! How can I help?' }; });
const res = await post(s.url, { messages: [{ role: 'user', text: 'Hello' }] });
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { reply: 'Hi! How can I help?' });
assert.deepEqual(received.contents, [{ role: 'user', parts: [{ text: 'Hello' }] }]);
assert.ok(received.config.systemInstruction.length > 0);
s.close();
});
test('rejects invalid input without calling the model', async () => {
let called = false;
const s = start(async () => { called = true; return { text: '' }; });
for (const body of [{}, { messages: [] }, { messages: [{ role: 'model', text: 'x' }] }, { messages: [{ role: 'user', text: 'x'.repeat(2001) }] }]) {
const res = await post(s.url, body);
assert.equal(res.status, 400);
}
assert.equal(called, false);
s.close();
});
test('returns 502 when Gemini fails', async () => {
const s = start(async () => { throw new Error('quota exceeded'); });
const origError = console.error; console.error = () => {};
const res = await post(s.url, { messages: [{ role: 'user', text: 'Hello' }] });
console.error = origError;
assert.equal(res.status, 502);
s.close();
});$ npm test
✔ forwards the conversation and returns the reply
✔ rejects invalid input without calling the model
✔ returns 502 when Gemini fails
ℹ pass 3Step 2: Build the Chat Screen
The app needs no AI SDK at all, just fetch. A small API module keeps the backend URL in one place:
export type ChatMessage = { role: 'user' | 'model'; text: string };
// Your own backend, never the Gemini API directly: the API key must not ship in the app.
// Android emulator: http://10.0.2.2:3000, iOS simulator: http://localhost:3000.
export const API_URL = 'http://10.0.2.2:3000';
export async function sendChat(messages: ChatMessage[], signal?: AbortSignal): Promise<string> {
const res = await fetch(`${API_URL}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal,
});
const data = await res.json();
if (!res.ok) throw new Error(data.error ?? `Request failed with ${res.status}`);
return data.reply;
}On the Android emulator, 10.0.2.2 reaches your computer’s localhost. Plain http:// works in React Native’s debug builds because the template allows cleartext traffic only in debug; release builds block it, so deploy the backend behind HTTPS.
The screen keeps the conversation in state, sends the last 20 messages, shows a typing indicator and errors, and scrolls to the newest message:
import { useRef, useState } from 'react';
import {
ActivityIndicator,
FlatList,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from 'react-native';
import { sendChat, type ChatMessage } from './api';
export function ChatScreen() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const listRef = useRef<FlatList<ChatMessage>>(null);
const send = async () => {
const text = input.trim();
if (!text || loading) return;
// Keep the last 20 messages so requests stay small.
const next = [...messages, { role: 'user' as const, text }].slice(-20);
setMessages(next);
setInput('');
setError(null);
setLoading(true);
try {
const reply = await sendChat(next);
setMessages(current => [...current, { role: 'model', text: reply }]);
} catch (e) {
setError(e instanceof Error ? e.message : 'Something went wrong');
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(_, i) => String(i)}
onContentSizeChange={() => listRef.current?.scrollToEnd({ animated: true })}
renderItem={({ item }) => (
<View style={[styles.bubble, item.role === 'user' ? styles.user : styles.bot]}>
<Text style={item.role === 'user' ? styles.userText : undefined}>{item.text}</Text>
</View>
)}
/>
{loading && <ActivityIndicator accessibilityLabel="Assistant is typing" />}
{error && <Text style={styles.error}>{error}</Text>}
<View style={styles.row}>
<TextInput
style={styles.input}
placeholder="Ask something"
value={input}
onChangeText={setInput}
onSubmitEditing={send}
editable={!loading}
/>
<Pressable
style={styles.send}
onPress={send}
disabled={loading}
accessibilityRole="button"
accessibilityLabel="Send">
<Text style={styles.sendText}>Send</Text>
</Pressable>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, padding: 12 },
bubble: { padding: 10, borderRadius: 12, marginVertical: 4, maxWidth: '80%' },
user: { alignSelf: 'flex-end', backgroundColor: '#2563eb' },
bot: { alignSelf: 'flex-start', backgroundColor: '#e5e7eb' },
userText: { color: '#fff' },
error: { color: '#b91c1c', marginVertical: 4 },
row: { flexDirection: 'row', gap: 8, alignItems: 'center' },
input: { flex: 1, borderWidth: 1, borderColor: '#d1d5db', borderRadius: 20, paddingHorizontal: 14, minHeight: 44 },
send: { paddingHorizontal: 16, paddingVertical: 12, borderRadius: 20, backgroundColor: '#2563eb' },
sendText: { color: '#fff', fontWeight: '600' },
});Compared with the old version, it drops Axios (built-in fetch is enough), the screen-size helper library and the deprecated react-native link command (native modules link automatically), and every placeholder “add your code here” block has been replaced by working code.
Test the screen
import { render, screen, userEvent } from '@testing-library/react-native';
import { ChatScreen } from '../ChatScreen';
afterEach(() => jest.restoreAllMocks());
test('sends the conversation to the backend and shows the reply', async () => {
const fetchMock = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({ reply: 'You can reset it in Settings.' }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
const user = userEvent.setup();
await render(<ChatScreen />);
await user.type(screen.getByPlaceholderText('Ask something'), 'How do I reset my password?');
await user.press(screen.getByRole('button', { name: 'Send' }));
expect(await screen.findByText('You can reset it in Settings.')).toBeOnTheScreen();
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.messages).toEqual([{ role: 'user', text: 'How do I reset my password?' }]);
});
test('shows the server error message', async () => {
globalThis.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 502,
json: async () => ({ error: 'The assistant is unavailable. Please try again.' }),
}) as unknown as typeof fetch;
const user = userEvent.setup();
await render(<ChatScreen />);
await user.type(screen.getByPlaceholderText('Ask something'), 'Hello');
await user.press(screen.getByRole('button', { name: 'Send' }));
expect(await screen.findByText('The assistant is unavailable. Please try again.')).toBeOnTheScreen();
});$ npx jest src/features/chat
PASS src/features/chat/__tests__/ChatScreen.test.tsxStep 3: Make It Production-Ready
- HTTPS and authentication. Deploy the backend behind HTTPS and require your app’s user session or token on
/chat, otherwise anyone who finds the URL can spend your quota. - Rate limits and budgets. Limit requests per user and set spending alerts on your Google Cloud billing account.
- Streaming. For long answers, stream the response so text appears as it is generated. The SDK supports streaming; on the app side you need a streaming-capable client, so start with the non-streaming version shown here.
- Persistence. Save conversations with AsyncStorage or your backend if users expect history after restarting the app, and tell them what you store.
- Safety and accuracy. Keep the system prompt focused on your use case, show that answers come from an AI, and do not let the model act on users’ behalf without confirmation.
- Images. Gemini accepts images as well as text. To support the “attach a photo” feature the old post sketched, upload the image to your backend and include it as an inline data part in
contents.
Conclusion
PaLM is gone, but the chatbot pattern is better than before: a thin React Native client, a small backend that owns the API key and the system prompt, and Gemini behind it. Keeping the model call on the server makes the app safer, lets you change models without an app release, and makes the whole thing easy to test with a fake client. For managing the rest of your app’s state, see Redux Toolkit with React Native.
FAQs
Can I still use the PaLM API?
No. Google decommissioned the PaLM API for Google AI services on August 15, 2024. Use the Gemini API instead.
Can I call the Gemini API directly from a React Native app?
Not with your API key in production. Keys compiled into an app can be extracted, and Google’s guidance is to call the API from a backend that keeps the key secret.
Which Gemini model should I use for a chatbot?
Check Google’s models page when you build, because models change often. As of October 2026 it recommends the latest Flash and Flash-Lite models for new projects; Flash-Lite is the cheaper, faster option. Keep the model name in configuration.
How does the chatbot remember the conversation?
In this design the app sends the recent message history with each request, and the backend passes it to Gemini as contents. Gemini itself does not remember earlier requests unless you use a stateful API.
Why does my request fail on a real Android device?
Release builds block plain HTTP, and 10.0.2.2 only works on the emulator. Use your backend’s public HTTPS URL, or your computer’s network address for testing on a device on the same Wi-Fi.
Do I need Axios?
No. React Native includes fetch, which is enough for JSON requests like these.








