Add CloudBase AI (DeepSeek / Hunyuan) to a React Native App
In one sentence: RN 0.74+ mobile apps use a two-stage "frontend fetch + backend proxy" pattern. The backend Route Handler (Next.js / Cloud Function / Cloud Run, your choice) calls
app.ai().createModel('cloudbase').streamText({model: 'deepseek-v4-flash'})via@cloudbase/node-sdkand returns atext/plainstreaming Response. The RN frontend reads the stream withfetch + body.getReader()(orreact-native-fetch-apipolyfill / XHRonprogressfallback) and renders text character by character in a FlatList. SecretId / SecretKey must never appear in the JS bundle — RN bundles are plaintext, and any app can be decompiled to extract credentials.Estimated time: 45 minutes | Difficulty: Intermediate
Applicable Scenarios
- Building a cross-platform RN app (one codebase for iOS + Android) that needs AI for conversation, summarization, translation, or copywriting
- Migrating an existing iOS / Android native app to RN for the AI layer, reusing the same CloudBase AI backend proxy already serving the web frontend
- Already using add-ai-nextjs for the web frontend and want to reuse the same Route Handler in an RN app — the backend stays completely unchanged, you only add the RN frontend streaming reader
Not applicable:
- Expo Go environment (Expo's official precompiled Go app locks native modules, preventing installation of polyfills like
react-native-fetch-apithat require native linking) — you must use an Expo dev build or a bare RN project instead - WeChat Mini Program — Mini Programs have built-in
wx.cloudidentity; use add-ai-wechat-miniprogram directly, no backend proxy required - Pure web / H5 — use add-ai-nextjs; the
@cloudbase/js-sdk + signInAnonymouslypattern is only meaningful in browser demos and does not work in RN - Importing
@cloudbase/node-sdkdirectly inside RN — the Node SDK depends on Node built-ins (fs / http / crypto) that cannot run in RN (Hermes / JSC)
Why RN Must Use a Backend Proxy
Like web apps, RN clients have no trusted credential holder:
- The JS bundle produced by RN is plaintext (even release-mode Hermes bytecode can be decompiled back to JS); putting SecretId / SecretKey in your code is the same as publishing them publicly
@cloudbase/js-sdk'ssignInAnonymously()can run in RN, but anonymous identities are heavily rate-limited by default (see Web SDK Security Policy); production requests will be blocked by risk controls- The reason Mini Programs can call
wx.cloud.extend.AIdirectly is thatwx.cloudcalls carry the user's WeChat login identity — RN has no equivalent platform identity injection mechanism
The standard pattern for CloudBase AI in RN is therefore:
RN App (fetch) ──HTTPS──▶ Backend Route Handler (holds SecretKey) ──▶ CloudBase AI streamText ──▶ Streaming response back to RN
The backend Route Handler is identical to the one in add-ai-nextjs Step 3. This recipe does not repeat that work — it only covers how to read the stream from the RN frontend.
Prerequisites
| Dependency | Version |
|---|---|
| React Native | 0.74+ (New Architecture / Fabric stable, Hermes by default; fetch supports body.getReader() in most scenarios) |
| Node.js (dev machine) | ≥ 20 |
| Xcode (iOS) | ≥ 15.0, iOS deployment target ≥ 13.4 |
| Android Studio | ≥ 2024.1, Android minSdkVersion ≥ 24 |
@cloudbase/node-sdk (backend) | ≥ 3.16.0 (required by the AI module) |
react-native-fetch-api (optional polyfill) | ≥ 3.0.0 (install for older RN versions or abnormal fetch behavior) |
| CloudBase environment | Provisioned, with "AI+" capability enabled in the Console |
Recommended project templates:
- New project:
npx @react-native-community/cli@latest init MyApp— bare RN 0.76+ with New Architecture by default - Cross-platform + EAS Build: use an Expo dev build (
npx create-expo-app + npx expo run:ios) — do not use Expo Go
Importing
@cloudbase/node-sdkdirectly on the client side is not possible — the Node SDK depends onfs / http / crypto, which Metro cannot bundle and Hermes cannot run. The server must be a real Node environment (Next.js Route Handler, Cloud Function, or Cloud Run).
Step 1: Enable AI Capability in the Console and Select a Model
This step is identical to add-ai-nextjs Step 1:
- Open the CloudBase Console → select your environment → AI+ → Quick Setup
- On first visit, click "Enable Now" to automatically inject AI invocation permissions into the environment. Enabling is free; calls are billed per token
- Under "Model Management" you can see the list of models available in the current environment. CloudBase provides unified access to DeepSeek, MiniMax, Hunyuan, Kimi, GLM, and other mainstream models via Token Resource Packages, with
deepseek-v4-flashas the official recommended default (cost-effective, general-purpose). See the full list at Model Access
All examples below use deepseek-v4-flash.
Step 2: Backend Route Handler (Same as the Next.js Recipe — Reuse Directly)
The backend proxy can be deployed to three places; pick one. The interface is identical in all cases:
| Deployment | Entry point | Credential injection |
|---|---|---|
| Next.js deployed to Vercel / self-hosted | app/api/chat/route.ts (App Router) | .env with TENCENTCLOUD_SECRETID/KEY |
| CloudBase Cloud Run | Any Node web framework (Express / Hono / Next.js) /api/chat route | Auto-injected by the container — no manual credential setup |
| CloudBase Cloud Function | HTTP trigger, tcb.init() in function code | Same — auto-injected |
Code example using a Next.js Route Handler (app/api/chat/route.ts):
import tcb from '@cloudbase/node-sdk';
export const runtime = 'nodejs'; // Critical: cannot use edge; the SDK depends on Node APIs
let app: ReturnType<typeof tcb.init> | null = null;
function getAi() {
if (!app) {
// node-sdk reads credentials automatically from TENCENTCLOUD_SECRETID/SECRETKEY
// timeout 60s: AI long-output scenarios will exhaust the default 15s
app = tcb.init({ env: process.env.CLOUDBASE_ENV!, timeout: 60000 });
}
return app.ai();
}
export async function POST(req: Request) {
const { messages } = await req.json();
const ai = getAi();
const model = ai.createModel('cloudbase');
const result = await model.streamText({
model: 'deepseek-v4-flash',
messages,
});
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of result.textStream) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
} catch (err) {
controller.error(err);
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
// RN fetch on Android occasionally fails to read the full stream due to keep-alive connection reuse; explicitly close the connection
'Connection': 'close',
},
});
}
Key points:
- The server must use
@cloudbase/node-sdk. Do not use the@cloudbase/js-sdk + signInAnonymously()web pattern (see "Why RN Must Use a Backend Proxy" above) runtime = 'nodejs', notedgetcb.init({ timeout: 60000 })explicitly sets a 60s timeout; the default 15s will be exhausted by long streaming outputs- Returns a
text/plainstreaming Response; do not return SSEtext/event-stream— RN has no native EventSource; use either thefetch + readerapproach in this recipe or installreact-native-sseto handle the SSE protocol separately - When deployed to CloudBase Cloud Run / Cloud Function, credentials are auto-injected — never hardcode them
If the backend runs on CloudBase Cloud Run, the frontend call URL looks like https://<service>-<envId>.ap-shanghai.app.tcloudbase.com/api/chat; on Vercel it is https://<your-app>.vercel.app/api/chat; on a self-hosted machine you need to set up HTTPS and a domain yourself.
The domain must use HTTPS. iOS ATS (App Transport Security) blocks HTTP by default; Android 9+ (API 28+) also disables cleartext traffic by default. See Step 4 for how to configure HTTP exceptions during local development.
Step 3: RN Frontend — Install Dependencies
From the RN project root:
# RN 0.74+ Hermes supports body.getReader() in most fetch scenarios; install polyfill as a safety net
npm install react-native-fetch-api react-native-polyfill-globals
# Link native dependencies for iOS
cd ios && pod install && cd ..
react-native-fetch-api provides a fetch implementation for RN that correctly supports await body.getReader().read(). Older RN versions wrap fetch around XHR, which cannot return a ReadableStream at all; 0.74+ mostly supports it, but the polyfill acts as a double safety net.
Enable the polyfill at the very top of your app entry point (first line of index.js):
// index.js
import { polyfillGlobal } from 'react-native/Libraries/Utilities/PolyfillFunctions';
import { fetch, Headers, Request, Response } from 'react-native-fetch-api';
polyfillGlobal('fetch', () =>
(...args) => fetch(args[0], { ...args[1], reactNative: { textStreaming: true } })
);
polyfillGlobal('Headers', () => Headers);
polyfillGlobal('Request', () => Request);
polyfillGlobal('Response', () => Response);
// Your original RN entry logic
import { AppRegistry } from 'react-native';
import App from './App';
import { name as appName } from './app.json';
AppRegistry.registerComponent(appName, () => App);
The non-standard option reactNative: { textStreaming: true } is the switch react-native-fetch-api uses to enable "true streaming" mode. Without it, fetch falls back to waiting for the complete response body before resolving — which looks like "a pause followed by the full response appearing at once" in the UI.
Step 4: RN Frontend — iOS / Android Network Configuration
iOS (ios/MyApp/Info.plist)
Production HTTPS requires no ATS configuration changes. For local development using HTTP (e.g. connecting to an internal network http://192.168.x.x:3000), add a temporary exception:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>192.168.1.100</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
</dict>
</dict>
</dict>
Do not enable NSAllowsArbitraryLoads in production — App Store review will reject it.
Android
In android/app/src/main/AndroidManifest.xml, add the INTERNET permission outside <application> and reference the network security config inside <application>:
<uses-permission android:name="android.permission.INTERNET" />
<application
android:networkSecurityConfig="@xml/network_security_config"
...>
Android 9+ (API 28+) blocks cleartext HTTP by default. Production HTTPS does not need this file; for local HTTP development, create android/app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.1.100</domain>
<domain includeSubdomains="true">10.0.2.2</domain>
</domain-config>
</network-security-config>
10.0.2.2 is the special address Android emulators use to reach the host machine. Do not enable cleartextTrafficPermitted="true" globally in production.
Step 5: RN Frontend — Consuming the Stream with fetch + ReadableStream
App.tsx:
import React, { useRef, useState } from 'react';
import {
FlatList,
KeyboardAvoidingView,
Platform,
SafeAreaView,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
// URL of the backend Route Handler (CloudBase Cloud Run / Vercel / self-hosted all work)
const API_URL = 'https://your-backend.example.com/api/chat';
type Message = { role: 'user' | 'assistant'; content: string };
export default function App() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const listRef = useRef<FlatList<Message>>(null);
async function send() {
const text = input.trim();
if (!text || loading) return;
const userMsg: Message = { role: 'user', content: text };
const aiMsg: Message = { role: 'assistant', content: '' };
const next = [...messages, userMsg, aiMsg];
setMessages(next);
setInput('');
setLoading(true);
try {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: [...messages, userMsg] }),
// @ts-ignore react-native-fetch-api extended option
reactNative: { textStreaming: true },
});
if (!res.ok || !res.body) {
throw new Error(`HTTP ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let acc = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
// stream: true is required — a Chinese character is typically 3 bytes in UTF-8,
// and a chunk boundary may split a character in half; without stream: true it becomes \uFFFD
acc += decoder.decode(value, { stream: true });
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = { role: 'assistant', content: acc };
return copy;
});
// Scroll FlatList to bottom on each chunk
listRef.current?.scrollToEnd({ animated: false });
}
} catch (err) {
console.error('[chat] fetch failed', err);
setMessages((prev) => {
const copy = [...prev];
copy[copy.length - 1] = {
role: 'assistant',
content: `[Error] ${err instanceof Error ? err.message : String(err)}`,
};
return copy;
});
} finally {
setLoading(false);
}
}
return (
<SafeAreaView style={styles.safe}>
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<FlatList
ref={listRef}
data={messages}
keyExtractor={(_, i) => String(i)}
contentContainerStyle={styles.list}
renderItem={({ item }) => (
<View
style={[
styles.bubble,
item.role === 'user' ? styles.userBubble : styles.aiBubble,
]}
>
<Text style={styles.role}>{item.role}</Text>
<Text style={styles.content}>{item.content}</Text>
</View>
)}
/>
<View style={styles.inputBar}>
<TextInput
style={styles.input}
value={input}
onChangeText={setInput}
placeholder="Ask something"
editable={!loading}
onSubmitEditing={send}
returnKeyType="send"
/>
<TouchableOpacity
style={[styles.button, (loading || !input.trim()) && styles.buttonDisabled]}
disabled={loading || !input.trim()}
onPress={send}
>
<Text style={styles.buttonText}>{loading ? 'Generating' : 'Send'}</Text>
</TouchableOpacity>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safe: { flex: 1, backgroundColor: '#fff' },
container: { flex: 1 },
list: { padding: 12, gap: 8 },
bubble: { padding: 10, borderRadius: 8 },
userBubble: { backgroundColor: '#e6f0ff', alignSelf: 'flex-end', maxWidth: '80%' },
aiBubble: { backgroundColor: '#f5f5f5', alignSelf: 'flex-start', maxWidth: '80%' },
role: { fontSize: 11, color: '#888', marginBottom: 4 },
content: { fontSize: 15, color: '#111', lineHeight: 22 },
inputBar: {
flexDirection: 'row',
padding: 8,
borderTopWidth: 1,
borderTopColor: '#eee',
gap: 8,
},
input: {
flex: 1,
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 6,
backgroundColor: '#f5f5f5',
fontSize: 15,
},
button: {
paddingHorizontal: 16,
justifyContent: 'center',
backgroundColor: '#1677ff',
borderRadius: 6,
},
buttonDisabled: { backgroundColor: '#aaa' },
buttonText: { color: '#fff', fontSize: 15 },
});
Key points:
decoder.decode(value, { stream: true })— thestream: trueflag is mandatory. A Chinese character typically occupies 3 bytes in UTF-8, and a chunk boundary may split a character in half; withoutstream: true, that half-character is decoded as the\uFFFDreplacement character- Pre-push an assistant message with empty content, then replace the last message on every
setStatecall — the UI grows in place rather than showing a blank bubble followed by the full response appearing at once - Calling
FlatList'sscrollToEnd({ animated: false })on every chunk is fine — FlatList is a virtualized list and only re-lays visible cells. The real bottleneck to throttle issetState, not scroll: if the model outputs very quickly (flash-series models likedeepseek-v4-flashcan emit hundreds of characters at once), add 80 ms throttling (see thelastFlushAtapproach in add-ai-wechat-miniprogram Step 3)
Step 6: XHR onprogress Fallback if fetch + ReadableStream Fails
On some RN versions (especially < 0.74) or certain devices, body.getReader() may still not yield streaming data even with the polyfill installed. Fall back to XMLHttpRequest's onprogress — XHR is the stable network foundation RN has always supported:
function streamChatViaXHR(
messages: Message[],
onChunk: (acc: string) => void,
onDone: () => void,
onError: (err: Error) => void,
) {
const xhr = new XMLHttpRequest();
xhr.open('POST', API_URL, true);
xhr.setRequestHeader('Content-Type', 'application/json');
let lastIndex = 0;
xhr.onprogress = () => {
// xhr.responseText is the accumulated response text up to this point; slice out the new portion
const newChunk = xhr.responseText.substring(lastIndex);
lastIndex = xhr.responseText.length;
onChunk(xhr.responseText); // Pass the full accumulated text to the UI (simple approach)
// To get only the increment, use newChunk instead
void newChunk;
};
xhr.onerror = () => onError(new Error(`XHR error: ${xhr.statusText}`));
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) onDone();
else onError(new Error(`HTTP ${xhr.status}`));
};
xhr.send(JSON.stringify({ messages }));
}
XHR fallback limitations:
- No true "byte-level stream" — only a "cumulative text view" (
xhr.responseText). Content-Type must be text (text/plain); binary responses will be garbled onprogressfire frequency depends on the device and network stack; on slow networks it may fire only once every few seconds — not as tight as thefetchreader- Higher risk of garbled multi-byte characters at chunk boundaries (XHR decodes internally using a UTF-8 buffer, which may still produce
\uFFFDat slice boundaries); you can force flush at token boundaries on the backendstreamTextlayer (advanced, not covered here)
Recommended production path: prefer fetch + getReader, fall back to XHR on failure — a simple try/catch nesting handles this.
Verification
Running the Backend
- Deploy to CloudBase Cloud Run:
tcb framework deployor upload via the Console (see Service Deployment) - Deploy to Vercel:
vercel --prod, and configureTENCENTCLOUD_SECRETID/KEY/CLOUDBASE_ENVin Vercel environment variables - Self-hosted:
npm run build && npm run start, with a reverse proxy for HTTPS
Test that the backend is streaming correctly with curl:
curl -N -X POST https://your-backend.example.com/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Introduce CloudBase in one sentence"}]}'
-N is --no-buffer; text should appear in increments. If the response waits and then dumps everything at once, the backend is not truly streaming — check that the ReadableStream code in the Route Handler is correct.
Running the RN App
- iOS:
npx react-native run-iosor launch from Xcode - Android:
npx react-native run-androidornpx expo run:android - Once the app is running, type "Introduce CloudBase in one sentence" and tap Send
- The reply should appear incrementally, not after a long pause
- Console → AI+ → Call Records should show the token count for that call
The iOS Console / Android logcat should not show errors like Network request failed / cleartext HTTP traffic not permitted / ATS blocked.
Common Errors
| Error / Symptom | Cause | Fix |
|---|---|---|
body.getReader is not a function | The default RN fetch implementation does not support ReadableStream (versions before 0.74, or some engines are incomplete) | Install react-native-fetch-api and call polyfillGlobal('fetch', ...) in the entry file — see Step 3 |
Network request failed with no detail | iOS ATS rejected HTTP (local dev http://192.168.x.x) or Android cleartext was blocked | iOS: update Info.plist NSAppTransportSecurity exceptions; Android: configure network_security_config.xml — see Step 4. Use HTTPS in production |
cleartext HTTP traffic to ... not permitted (Android 9+) | Android blocks cleartext HTTP by default | Same as above — add a network_security_config.xml whitelist |
| fetch does not error but the frontend shows "a pause then the full response at once" | reactNative: { textStreaming: true } is missing; fetch falls back to waiting for the complete response | Add reactNative: { textStreaming: true } to both the polyfill entry and the fetch call |
Streaming Chinese text shows ��� garbled characters | TextDecoder.decode(value) is missing { stream: true }; multi-byte characters split across chunk boundaries are corrupted | Change to decoder.decode(value, { stream: true }) — see Step 5 |
Backend reports XMLHttpRequest is not defined or secretId or secretKey not found | The backend Route Handler is using Edge Runtime, or the deployment platform has no credentials configured | Backend must export const runtime = 'nodejs'; for non-CloudBase platforms, explicitly set TENCENTCLOUD_SECRETID/KEY — see add-ai-nextjs |
Backend reports timeout / hangs around 60s | Node SDK default timeout: 15000 is too short | Set tcb.init({ env, timeout: 60000 }) to 60s or more — see Step 2 code |
model not found / model xxx is not supported | Model ID is misspelled or not available in your environment | Check "Model Management" in the Console for the exact name. Do not copy OpenAI / Anthropic naming conventions. The recommended default is deepseek-v4-flash; see Model Access for the full list |
Expo Go crashes when installing the polyfill / pod install errors | Expo Go locks native modules | Switch to an Expo dev build or bare RN — Expo Go is not supported |
| SecretKey visible in decompiled release build | Credentials were placed in client-side code | Credentials must never go in the client — backend proxy only. This is called out in the introduction |
For the complete error code reference, see https://docs.cloudbase.net/error-code/.
Billing Notes
- Newly provisioned environments receive 1 million free token credits for the first month (see the Console billing page for the current quota)
- Billing is calculated separately for "input tokens + output tokens"; unit prices vary by model. Streaming is only a different transport mode — token billing is identical to non-streaming
- The backend Route Handler is a public endpoint. Mobile app traffic is inherently harder to rate-limit than browser traffic (which has same-origin restrictions). Always add request authentication:
- Have the RN app obtain a user identity token on launch (see the server-side verification approach in add-auth-web-with-cloudbase-sdk; RN can use
@cloudbase/js-sdkto obtain a JWT) - Verify the JWT at the Route Handler entry point — return 401 on failure — then rate-limit per UID
- You can also use CloudBase Security Controls to configure domain / Referer allowlists, but RN apps do not send a Referer header, so token authentication is the only effective approach for mobile
- Have the RN app obtain a user identity token on launch (see the server-side verification approach in add-auth-web-with-cloudbase-sdk; RN can use
- The Apple App Store does not allow apps with unrestricted AI conversation features without content moderation — before submitting, add Text Content Security checks in the Route Handler, or use Agent mode to have the platform handle sensitive content filtering
Related Documentation
- add-ai-nextjs — complete backend Route Handler implementation that this recipe reuses directly
- add-ai-wechat-miniprogram — Mini Program counterpart (
wx.cloud.extend.AIwith built-in identity, no backend proxy needed) - connect-openai-api-cloud-function — alternative for international businesses using OpenAI via a Cloud Function proxy
- add-auth-web-with-cloudbase-sdk — real user identity for Web / RN with CloudBase, enabling per-UID rate limiting at the Route Handler entry
- SDK Initialization and Invocation — official initialization guide for
app.ai()(Node.js server-side) - SDK API Reference — complete signatures for
createModel / generateText / streamText - CloudBase AI Toolkit — full CloudBase integration for AI IDEs such as Cursor, Windsurf, and CodeBuddy
- React Native Official Networking Guide — official RN documentation for fetch, XHR, and WebSocket