Skip to main content

Command Palette

Search for a command to run...

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

Updated
7 min read
Building a Local AI Code Reviewer with React Native, Ollama, and Supabas
A

I am developer from India.

GitHub: github.com/adityakmr7/ai-code-reviewer


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-ai package 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.