Building a Local AI Code Reviewer with React Native, Ollama, and Supabas

I am developer from India.
I wanted a code review tool that actually runs locally — no sending my code to a third-party API, no monthly bill, no rate limits. So I built one: a cross-platform mobile + web app that streams structured AI code reviews and keeps a persistent chat history, all backed by a local Llama3 model via Ollama.
Here's what it looks like in practice:
Paste any code snippet, pick a language, hit review
Get a streamed, structured breakdown: issues, improvements, refactored code, and a complexity rating
Chat with the AI across multiple conversations, with full history persisted per user
Runs on iOS, Android, and web from a single codebase
Stack at a Glance
| Layer | Tech |
|---|---|
| Mobile + Web | React Native / Expo Router |
| Backend | Express.js on Bun |
| LLM | Ollama (Llama3, local) |
| Database + Auth | Supabase (Postgres + JWT) |
| Language | TypeScript throughout |
The repo is a monorepo with two workspaces: backend/ and ai-chat-expo/. A single npm run dev at the root spins both up via concurrently.
Why Ollama?
The whole point was to keep code off external APIs. Ollama runs Llama3 locally on your machine and exposes a dead-simple HTTP endpoint. For a streaming response you just POST to http://localhost:11434/api/chat with a message array and read the NDJSON back. No API key, no quota, no latency from a remote data center.
The tradeoff: anyone running this needs Ollama installed and a model pulled (ollama pull llama3). For a personal tool, that's a totally acceptable setup cost.
The Backend
The backend is a thin Express server. The interesting parts are llmService.ts, codeReviewController.ts, and the auth middleware.
Streaming from Ollama
// backend/services/llmService.ts
export async function generateChatStream(messages: Message[]): Promise<ReadableStream> {
const recentMessages = messages.slice(-10); // cap context window
const response = await fetch("http://localhost:11434/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "llama3",
messages: recentMessages,
stream: true,
}),
});
return response.body!;
}
Keeping only the last 10 messages isn't just a token optimization — it also keeps latency predictable. Llama3 on a mid-range machine can get sluggish with a long context.
The controller pipes the stream directly to the HTTP response:
// backend/controllers/chatController.ts
res.setHeader("Content-Type", "text/plain");
res.setHeader("Transfer-Encoding", "chunked");
const stream = await generateChatStream(messages);
const reader = stream.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
// Ollama sends NDJSON — parse each line
for (const line of chunk.split("\n").filter(Boolean)) {
const json = JSON.parse(line);
if (json.message?.content) {
res.write(json.message.content);
}
}
}
res.end();
Structured Code Review via Prompt Engineering
The code review endpoint uses a system prompt that forces a consistent output format:
const systemPrompt = `You are a senior software engineer doing a code review.
Always respond in exactly this format:
🔴 **Issues**
...
🟡 **Improvements**
...
🟢 **Refactored Code**
\`\`\`{language}
...
\`\`\`
🧠 **Explanation**
...
⚡ **Complexity**
...`;
Emoji anchors sound gimmicky but they're actually robust delimiters for a regex parser — far more reliable than asking the model to output JSON, which it tends to format inconsistently.
Auth Middleware
Every route is protected by a middleware that validates the Supabase JWT:
// backend/middleware/authMiddleware.ts
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
const token = req.headers.authorization?.split("Bearer ")[1];
if (!token) return res.status(401).json({ error: "Unauthorized" });
const { data, error } = await supabase.auth.getUser(token);
if (error || !data.user) return res.status(401).json({ error: "Invalid token" });
req.token = token;
req.user = data.user;
next();
}
The token is then used to create a per-request Supabase client scoped to that user's JWT. This means Postgres Row-Level Security does the ownership enforcement automatically — no manual WHERE user_id = ? filters needed anywhere.
The Frontend
Expo Router File-Based Routing
The route structure maps cleanly to the app's two states — authenticated and unauthenticated:
app/
(auth)/
login.tsx
signup.tsx
(tabs)/
index.tsx ← Home
chat.tsx ← Conversation list + chat
code-review.tsx ← Code review input + result
profile.tsx
Expo Router handles the group conventions — (auth) and (tabs) aren't URL segments, just layout groupings. The tab bar only renders inside (tabs).
New Architecture and React Compiler are both enabled in app.json, which means the JS thread isn't blocked by the bridge and most re-renders are automatically memoized.
The Streaming Problem on Native
This is the part that actually required real thought. On web, streaming is straightforward:
const response = await fetch(url, { ... });
const reader = response.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield new TextDecoder().decode(value);
}
On React Native, fetch doesn't expose response.body as a readable stream. The workaround is XMLHttpRequest with an onprogress handler that reads the incrementally growing responseText:
// utils/api.ts
let lastIndex = 0;
xhr.onprogress = () => {
const newChunk = xhr.responseText.slice(lastIndex);
lastIndex = xhr.responseText.length;
onChunk(newChunk);
};
Both paths (native XHR and web Fetch) live in streamCodeReview() behind a Platform.OS check, so the calling component doesn't need to care.
Parsing the Streamed Markdown
Because the review arrives in chunks, the parser needs to work on partial input. parseCodeReview.ts runs regex extraction on whatever text has arrived so far:
export function parseCodeReview(raw: string): CodeReview {
return {
issues: extractSection(raw, "🔴"),
improvements: extractSection(raw, "🟡"),
refactoredCode: extractCodeBlock(raw),
explanation: extractSection(raw, "🧠"),
complexity: extractSection(raw, "⚡"),
};
}
function extractSection(raw: string, emoji: string): string {
const match = raw.match(new RegExp(`\({emoji}[^\\n]*\\n([\\s\\S]*?)(?=🔴|🟡|🟢|🧠|⚡|\))`));
return match?.[1]?.trim() ?? "";
}
The component calls this on every new chunk, so the UI fills in section by section as the model generates it.
Platform-Aware Supabase Client
Supabase's JS SDK expects localStorage for session persistence, which doesn't exist on React Native. The fix is a custom storage adapter:
// utils/supabase.ts
const ExpoStorage = {
getItem: async (key: string) => {
if (Platform.OS === "web") return localStorage.getItem(key);
return AsyncStorage.getItem(key);
},
setItem: async (key: string, value: string) => {
if (Platform.OS === "web") return localStorage.setItem(key, value);
return AsyncStorage.setItem(key, value);
},
removeItem: async (key: string) => {
if (Platform.OS === "web") return localStorage.removeItem(key);
return AsyncStorage.removeItem(key);
},
};
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { storage: ExpoStorage, autoRefreshToken: true, persistSession: true },
});
Running It Yourself
Prerequisites: Ollama installed, llama3 model pulled, Supabase project created.
# Clone
git clone https://github.com/adityakmr7/ai-code-reviewer.git
cd ai-code-reviewer
# Backend env
cp backend/.env.example backend/.env
# Fill in SUPABASE_URL, SUPABASE_KEY, PORT=3000
# Frontend env
cp ai-chat-expo/.env.example ai-chat-expo/.env
# Fill in EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_SUPABASE_ANON_KEY
# Install and run
npm install
npm run dev
On a separate terminal:
ollama serve
What's Next
Swap Ollama for Gemini (the
@google/generative-aipackage is already installed, just not wired up)PR diff review — paste a diff instead of a file
Multiple model selection in the UI
Push notifications when a long review finishes
The full source is on GitHub: github.com/adityakmr7/ai-code-reviewer
If you have questions or want to contribute, open an issue or PR. Happy to discuss the streaming architecture or the Supabase RLS setup in more detail.

