Skip to main content

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-sdk and returns a text/plain streaming Response. The RN frontend reads the stream with fetch + body.getReader() (or react-native-fetch-api polyfill / XHR onprogress fallback) 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-api that 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.cloud identity; use add-ai-wechat-miniprogram directly, no backend proxy required
  • Pure web / H5 — use add-ai-nextjs; the @cloudbase/js-sdk + signInAnonymously pattern is only meaningful in browser demos and does not work in RN
  • Importing @cloudbase/node-sdk directly 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's signInAnonymously() 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.AI directly is that wx.cloud calls 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

DependencyVersion
React Native0.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 environmentProvisioned, 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-sdk directly on the client side is not possible — the Node SDK depends on fs / 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:

  1. Open the CloudBase Console → select your environment → AI+Quick Setup
  2. On first visit, click "Enable Now" to automatically inject AI invocation permissions into the environment. Enabling is free; calls are billed per token
  3. 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-flash as 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:

DeploymentEntry pointCredential injection
Next.js deployed to Vercel / self-hostedapp/api/chat/route.ts (App Router).env with TENCENTCLOUD_SECRETID/KEY
CloudBase Cloud RunAny Node web framework (Express / Hono / Next.js) /api/chat routeAuto-injected by the container — no manual credential setup
CloudBase Cloud FunctionHTTP trigger, tcb.init() in function codeSame — 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', not edge
  • tcb.init({ timeout: 60000 }) explicitly sets a 60s timeout; the default 15s will be exhausted by long streaming outputs
  • Returns a text/plain streaming Response; do not return SSE text/event-stream — RN has no native EventSource; use either the fetch + reader approach in this recipe or install react-native-sse to 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 }) — the stream: true flag is mandatory. A Chinese character typically occupies 3 bytes in UTF-8, and a chunk boundary may split a character in half; without stream: true, that half-character is decoded as the \uFFFD replacement character
  • Pre-push an assistant message with empty content, then replace the last message on every setState call — the UI grows in place rather than showing a blank bubble followed by the full response appearing at once
  • Calling FlatList's scrollToEnd({ animated: false }) on every chunk is fine — FlatList is a virtualized list and only re-lays visible cells. The real bottleneck to throttle is setState, not scroll: if the model outputs very quickly (flash-series models like deepseek-v4-flash can emit hundreds of characters at once), add 80 ms throttling (see the lastFlushAt approach 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
  • onprogress fire frequency depends on the device and network stack; on slow networks it may fire only once every few seconds — not as tight as the fetch reader
  • Higher risk of garbled multi-byte characters at chunk boundaries (XHR decodes internally using a UTF-8 buffer, which may still produce \uFFFD at slice boundaries); you can force flush at token boundaries on the backend streamText layer (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 deploy or upload via the Console (see Service Deployment)
  • Deploy to Vercel: vercel --prod, and configure TENCENTCLOUD_SECRETID/KEY/CLOUDBASE_ENV in 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

  1. iOS: npx react-native run-ios or launch from Xcode
  2. Android: npx react-native run-android or npx expo run:android
  3. Once the app is running, type "Introduce CloudBase in one sentence" and tap Send
  4. The reply should appear incrementally, not after a long pause
  5. 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 / SymptomCauseFix
body.getReader is not a functionThe 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 detailiOS ATS rejected HTTP (local dev http://192.168.x.x) or Android cleartext was blockediOS: 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 defaultSame 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 responseAdd reactNative: { textStreaming: true } to both the polyfill entry and the fetch call
Streaming Chinese text shows ��� garbled charactersTextDecoder.decode(value) is missing { stream: true }; multi-byte characters split across chunk boundaries are corruptedChange to decoder.decode(value, { stream: true }) — see Step 5
Backend reports XMLHttpRequest is not defined or secretId or secretKey not foundThe backend Route Handler is using Edge Runtime, or the deployment platform has no credentials configuredBackend must export const runtime = 'nodejs'; for non-CloudBase platforms, explicitly set TENCENTCLOUD_SECRETID/KEY — see add-ai-nextjs
Backend reports timeout / hangs around 60sNode SDK default timeout: 15000 is too shortSet tcb.init({ env, timeout: 60000 }) to 60s or more — see Step 2 code
model not found / model xxx is not supportedModel ID is misspelled or not available in your environmentCheck "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 errorsExpo Go locks native modulesSwitch to an Expo dev build or bare RN — Expo Go is not supported
SecretKey visible in decompiled release buildCredentials were placed in client-side codeCredentials 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-sdk to 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
  • 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