Connect a mobile app to your local API server
Tunnel your localhost API so React Native, Flutter, Swift, and Kotlin apps on real devices can reach it over HTTPS.
The problem: localhost is not universal
When a browser hits localhost:8080, it works because the browser and the server are on the same machine. Mobile development breaks this assumption. localhost means "this device," and your phone is not your laptop.
Every platform has a workaround, and every workaround has a catch.
Android emulator: Google maps the special IP 10.0.2.2 to the host machine's loopback. So your emulator can reach http://10.0.2.2:8080 and it resolves to localhost:8080 on your dev machine. This works until you pick up a physical Android phone to test. Real devices have no idea what 10.0.2.2 means. You end up with two code paths -- one for the emulator, one for hardware -- and you inevitably ship the wrong one.
iOS Simulator: The Simulator runs as a process on your Mac, so it shares the host network stack entirely. localhost:8080 works out of the box. But run that same build on a physical iPhone connected over USB or Wi-Fi, and the requests fail silently. The Simulator hides networking bugs that only surface on real hardware.
LAN IP address: You find your machine's IP with ifconfig, hardcode http://192.168.1.47:8080 into the app, and it works. Until DHCP assigns you a new address, or you switch from your home Wi-Fi to a coffee shop, or your colleague tries to run the app and wonders why the API is unreachable. LAN IPs are fragile and local -- they don't survive network changes.
The common thread: none of these approaches give you a single, stable URL that works on emulators, simulators, and physical devices across networks.
The fix: tunnel your API
Start your API server. Run taupi. Use the HTTPS URL everywhere.
# Your API, running locally
cd backend && npm run dev # listening on localhost:3000
# Tunnel it
taupi tunnel 3000
taupi prints something like:
Taupi
Status connected
URL https://xkfmao.taupi.dev
Target localhost:3000
That URL reaches your local server from any device with an internet connection. Phone on cellular, tablet on a different Wi-Fi, colleague across the country -- same URL, same local server.
With a Pro subscription, pin a subdomain so the URL survives restarts:
taupi tunnel -s my-api 3000
# https://my-api.taupi.dev
Now wire it into your mobile app.
React Native
Create a configuration file that switches between the tunnel in development and your production API:
// src/api/config.js
const API_BASE_URL = __DEV__
? 'https://my-api.taupi.dev'
: 'https://api.yourapp.com';
export { API_BASE_URL };
__DEV__ is a global that React Native sets to true in debug builds and false in release builds. No manual toggling.
Here is a full fetch example with auth headers:
// src/api/client.js
import { API_BASE_URL } from './config';
export async function apiFetch(path, { method = 'GET', body, token } = {}) {
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.text();
throw new Error(`API ${response.status}: ${error}`);
}
return response.json();
}
Usage from a component or hook:
import { apiFetch } from '../api/client';
const users = await apiFetch('/api/users', { token: authToken });
const created = await apiFetch('/api/users', {
method: 'POST',
body: { name: 'Ada', email: '[email protected]' },
token: authToken,
});
This works identically on an iOS Simulator, an Android emulator, and a physical phone. The only thing that changes between dev and prod is the base URL, and the config file handles that.
Flutter
Create an API client class with a dev/prod switch:
// lib/api/api_client.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
class ApiClient {
static const bool _isDev =
bool.fromEnvironment('dart.vm.product') == false;
static final String baseUrl = _isDev
? 'https://my-api.taupi.dev'
: 'https://api.yourapp.com';
final String? authToken;
final http.Client _client = http.Client();
ApiClient({this.authToken});
Future<Map<String, dynamic>> get(String path) async {
final response = await _client.get(
Uri.parse('$baseUrl$path'),
headers: _headers(),
);
return _handleResponse(response);
}
Future<Map<String, dynamic>> post(
String path, Map<String, dynamic> body) async {
final response = await _client.post(
Uri.parse('$baseUrl$path'),
headers: _headers(),
body: jsonEncode(body),
);
return _handleResponse(response);
}
Map<String, String> _headers() {
final headers = {'Content-Type': 'application/json'};
if (authToken != null) {
headers['Authorization'] = 'Bearer $authToken';
}
return headers;
}
Map<String, dynamic> _handleResponse(http.Response response) {
if (response.statusCode >= 200 && response.statusCode < 300) {
return jsonDecode(response.body);
}
throw Exception('API error ${response.statusCode}: ${response.body}');
}
}
Usage:
final api = ApiClient(authToken: token);
final users = await api.get('/api/users');
final created = await api.post('/api/users', {
'name': 'Ada',
'email': '[email protected]',
});
The bool.fromEnvironment('dart.vm.product') check is false in debug mode and true in release builds. If you prefer explicit control, pass the base URL as a compile-time variable:
flutter run --dart-define=API_BASE=https://my-api.taupi.dev
static final String baseUrl =
const String.fromEnvironment('API_BASE', defaultValue: 'https://api.yourapp.com');
iOS / Swift
Define the base URL using a compiler directive:
// APIConfig.swift
struct APIConfig {
#if DEBUG
static let baseURL = URL(string: "https://my-api.taupi.dev")!
#else
static let baseURL = URL(string: "https://api.yourapp.com")!
#endif
}
A URLSession request against the tunnel:
// APIClient.swift
func fetchUsers(token: String) async throws -> [User] {
var request = URLRequest(
url: APIConfig.baseURL.appendingPathComponent("api/users")
)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse,
(200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
return try JSONDecoder().decode([User].self, from: data)
}
HTTPS matters here. iOS enforces App Transport Security (ATS), which blocks cleartext HTTP by default. Since taupi tunnels serve valid TLS certificates, ATS allows the connection with zero configuration. No NSAppTransportSecurity exception in Info.plist, no NSAllowsArbitraryLoads. Your debug build and your release build have the same transport security posture.
Android / Kotlin
Configure Retrofit with the tunnel URL in a development build flavor:
// app/src/main/java/com/example/api/ApiConfig.kt
object ApiConfig {
val BASE_URL: String = if (BuildConfig.DEBUG) {
"https://my-api.taupi.dev"
} else {
"https://api.yourapp.com"
}
}
Set up the Retrofit client with OkHttp:
// app/src/main/java/com/example/api/ApiService.kt
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.GET
import retrofit2.http.Header
import okhttp3.OkHttpClient
interface ApiService {
@GET("api/users")
suspend fun getUsers(@Header("Authorization") auth: String): List<User>
}
object ApiClient {
private val okHttp = OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
val service: ApiService = Retrofit.Builder()
.baseUrl(ApiConfig.BASE_URL)
.client(okHttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(ApiService::class.java)
}
Usage from a ViewModel or coroutine:
val users = ApiClient.service.getUsers("Bearer $token")
Because the tunnel URL is HTTPS, you don't need android:usesCleartextTraffic="true" in your manifest. Android 9 and above block cleartext by default, and working with the tunnel means you never have to weaken that policy during development.
Hot reload workflow
This is where tunneling pays off. Your local server is running. The tunnel is live. Your phone is making API requests through the tunnel.
Change an API handler on your laptop. Save the file. Your dev server reloads (nodemon, air, watchexec, whatever you use). The next request from the phone hits the updated handler. No redeploy, no CI pipeline, no waiting for a staging environment to update.
# Terminal 1: API server with auto-reload
cd backend && npm run dev
# Terminal 2: tunnel
taupi tunnel 3000
# Terminal 3 (optional): run the mobile app
cd mobile && npx react-native run-android
Edit backend/routes/users.js, save, and the phone app immediately gets the new response. The feedback loop is measured in seconds.
Debugging with request inspection
When something goes wrong between the phone and the API, the question is always: is the phone sending the right request, or is the server handling it wrong? taupi's inspect mode answers that:
taupi tunnel --inspect 3000
Every request that flows through the tunnel gets printed with its method, path, headers, and body. You see exactly what the mobile app sent and what your server responded.
This is invaluable for debugging:
- Auth failures. The
Authorizationheader is missing or malformed. You see it immediately in the inspect output instead of guessing. - Wrong content type. The app is sending
text/plaininstead ofapplication/json. The inspect log shows the header mismatch. - Unexpected request paths. The app is hitting
/usersbut your server expects/api/users. The 404 response is right there. - Payload shape issues. The server expects
{ "email": "..." }but the app sends{ "user_email": "..." }. You see both the request body and the 400 response.
No need for Charles Proxy, mitmproxy, or Wireshark. The tunnel itself is your debugging layer.
Common issues
CORS in web views. If your mobile app uses a WebView that loads a page making fetch calls to your API, CORS applies. Your API needs to return Access-Control-Allow-Origin headers. This is not specific to tunneling -- it applies any time the WebView origin and the API origin differ. Add CORS middleware to your dev server:
// Express
const cors = require('cors');
app.use(cors({ origin: true }));
Certificate pinning. If your app pins certificates for the production API domain, those pins won't match the taupi tunnel certificate. Disable pinning in debug builds. In OkHttp:
val okHttp = if (BuildConfig.DEBUG) {
OkHttpClient.Builder().build() // no pinning
} else {
OkHttpClient.Builder()
.certificatePinner(productionPinner)
.build()
}
Latency. A tunnel adds a network hop. Expect 10-20ms of added latency on each request. This is imperceptible in normal app usage but can affect performance benchmarks. If you are profiling API response times, measure against localhost directly, not through the tunnel.
Slow initial connection. The first request after starting the tunnel may take slightly longer as the TLS handshake completes. Subsequent requests reuse the connection and are faster.
Multiple services
Running a REST API and a WebSocket server? Tunnel each one separately:
# Terminal 1: API tunnel
taupi tunnel -s my-api 3000
# https://my-api.taupi.dev
# Terminal 2: WebSocket tunnel
taupi tunnel -s my-ws 3001
# https://my-ws.taupi.dev
In your mobile app, configure both:
// src/api/config.js
const API_BASE_URL = __DEV__
? 'https://my-api.taupi.dev'
: 'https://api.yourapp.com';
const WS_URL = __DEV__
? 'wss://my-ws.taupi.dev'
: 'wss://ws.yourapp.com';
export { API_BASE_URL, WS_URL };
Note that taupi tunnel URLs support WebSocket upgrades, so wss:// connections through the tunnel work without any additional configuration. The same tunnel that handles your HTTP requests handles the WebSocket handshake and subsequent frames.