The most common point where Rork apps stall is the backend. "My data disappears when I restart" or "I want login functionality but don't know where to start" — these are exactly the questions this guide addresses.
Rork Max's auto-generated backend is capable of production quality when used correctly. The qualification matters: wrong design choices here create technical debt that is painful to unwind later.
Understanding Rork's Backend Architecture
Rork's backend runs on Cloudflare Workers — lightweight serverless functions where each API endpoint operates as an independent function invocation. Data storage uses either Cloudflare KV (key-value store) or D1 (SQLite-compatible relational database).
The design gives you high scalability and zero-configuration deployment. The constraint is that complex relational data models at large scale will eventually strain it.
KV vs D1: When to Use Each
Use Cloudflare KV for:
- Session data and caching
- User settings and preferences
- Data with TTL (expiration)
- High-frequency simple key lookups
Use Cloudflare D1 for:
- Relational data (users and their posts, etc.)
- Queries requiring complex search
- Data needing aggregation and analytics
- Schemas that are stable and well-defined
Data Persistence Implementation
D1-Based User Data
Tell Rork "use SQLite database to persist user data" and D1-backed backend code is generated. Understanding what gets generated lets you improve it.
// src/api/users.ts
import { D1Database } from "@cloudflare/workers-types";
export interface User {
id: string;
email: string;
display_name: string;
created_at: string;
updated_at: string;
}
export class UserRepository {
constructor(private db: D1Database) {}
async createUser(email: string, displayName: string): Promise<User> {
const id = crypto.randomUUID();
const now = new Date().toISOString();
await this.db.prepare(`
INSERT INTO users (id, email, display_name, created_at, updated_at)
VALUES (?, ?, ?, ?, ?)
`).bind(id, email, displayName, now, now).run();
return { id, email, display_name: displayName, created_at: now, updated_at: now };
}
async getUserByEmail(email: string): Promise<User | null> {
const result = await this.db
.prepare("SELECT * FROM users WHERE email = ?")
.bind(email)
.first<User>();
return result ?? null;
}
}The index problem Rork misses: Generated SQL is usually correct, but indexes are often omitted. Add them to your migration file:
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);Without these, query performance degrades noticeably once your database reaches a few thousand rows.
Authentication Implementation
JWT Auth Flow
Prompt Rork with "add JWT-based login functionality" and basic auth gets generated. Production requires a few improvements.
// src/api/auth.ts
import { SignJWT, jwtVerify } from "jose";
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET);
const TOKEN_EXPIRY = "7d";
export async function createSession(userId: string, email: string): Promise<string> {
return new SignJWT({ userId, email })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime(TOKEN_EXPIRY)
.sign(JWT_SECRET);
}
export async function verifySession(
token: string
): Promise<{ userId: string; email: string } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
return { userId: payload.userId as string, email: payload.email as string };
} catch {
return null;
}
}
// Middleware to protect authenticated endpoints
export function requireAuth(handler: Function) {
return async (request: Request, env: any) => {
const authHeader = request.headers.get("Authorization");
if (\!authHeader?.startsWith("Bearer ")) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
const token = authHeader.substring(7);
const session = await verifySession(token);
if (\!session) {
return new Response(JSON.stringify({ error: "Invalid or expired token" }), {
status: 401,
headers: { "Content-Type": "application/json" }
});
}
return handler(request, env, session);
};
}Secure Token Storage in the iOS App
Store JWT tokens in the Keychain, not UserDefaults. UserDefaults is readable by other processes on a jailbroken device.
// Services/AuthService.swift
import Security
import Foundation
class AuthService: ObservableObject {
@Published var isAuthenticated = false
private let keychainKey = "app.authToken"
func saveToken(_ token: String) {
let data = token.data(using: .utf8)\!
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
DispatchQueue.main.async {
self.isAuthenticated = true
}
}
func getToken() -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne
]
var result: AnyObject?
SecItemCopyMatching(query as CFDictionary, &result)
guard let data = result as? Data else { return nil }
return String(data: data, encoding: .utf8)
}
func logout() {
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: keychainKey]
SecItemDelete(query as CFDictionary)
DispatchQueue.main.async { self.isAuthenticated = false }
}
}External API Integration
Stripe Payment Integration
// src/api/payments.ts
export async function createPaymentIntent(
amount: number,
currency: string,
userId: string,
env: { STRIPE_SECRET_KEY: string }
): Promise<{ clientSecret: string }> {
const response = await fetch("https://api.stripe.com/v1/payment_intents", {
method: "POST",
headers: {
"Authorization": `Bearer ${env.STRIPE_SECRET_KEY}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
amount: String(amount),
currency,
metadata: JSON.stringify({ userId }),
}),
});
if (\!response.ok) {
const error = await response.json();
throw new Error(`Stripe error: ${error.error.message}`);
}
const intent = await response.json();
return { clientSecret: intent.client_secret };
}Never put secrets in code: Store STRIPE_SECRET_KEY in Cloudflare Workers environment variables, configured through Rork's project settings. Any secret in source code is one GitHub push away from being compromised.
Error Handling for Production
Error handling is consistently the most underdeveloped aspect of Rork-generated apps.
export class AppError extends Error {
constructor(
message: string,
public statusCode: number = 500,
public code: string = "INTERNAL_ERROR"
) {
super(message);
this.name = "AppError";
}
}
export function createErrorResponse(error: unknown): Response {
if (error instanceof AppError) {
return new Response(
JSON.stringify({ error: error.message, code: error.code }),
{ status: error.statusCode, headers: { "Content-Type": "application/json" } }
);
}
console.error("Unexpected error:", error);
return new Response(
JSON.stringify({ error: "Internal server error", code: "INTERNAL_ERROR" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}Migration Strategy When You Outgrow Rork
Rork's backend has limits worth knowing before you hit them. Complex relational models at scale, real-time features requiring WebSockets, and workloads above roughly 50,000 MAU start straining the architecture.
Practical migration targets: Supabase (strong auth and real-time), PlanetScale + Hono (scalable SQL). Both support the same API contracts Rork generates, so migrating the backend without touching the mobile app is technically feasible.
The right strategy for most apps: build with Rork, acquire users, then migrate when the specific limitation becomes real rather than theoretical. Over-engineering the backend before you have users to justify it wastes the time you should be spending on the product.