How to Structure Complex API Logic in 9router: A Modular Architecture Guide

Structure complex API logic in 9router by isolating Next.js route handlers, modular MITM proxy logic in src/mitm/handlers/, and provider-specific translators using the open-sse/translator registry.

9router is an open-source routing layer that normalizes requests between OpenAI-compatible clients and diverse AI providers. When you structure complex API logic in 9router following its established patterns, you create maintainable, streaming-ready endpoints without entangling business logic in your main application code.

Core Architectural Components

The recommended architecture separates concerns into three distinct layers that communicate through strict contracts.

Thin API Entry Points in Next.js Routes

Route files under src/app/api/v1/*/route.js should remain minimal delegators. They ensure initialization and forward requests to specialized handlers, keeping HTTP transport concerns separate from business logic.

Example from src/app/api/v1/chat/completions/route.js:

import { ensureInitialized } from './route.js';
import { handleCompletions } from '@/sse/handlers/completions.js';

export async function POST(request) {
  await ensureInitialized();
  return await handleCompletions(request);
}

See the full implementation at src/app/api/v1/chat/completions/route.js.

MITM Handlers for Proxy Logic

Complex forwarding logic belongs in src/mitm/handlers/. These modules consume the base utilities fetchRouter and pipeSSE from src/mitm/handlers/base.js to manage request forwarding and Server-Sent Events (SSE) streaming.

In src/mitm/handlers/kiro.js, the pattern demonstrates intercepting requests, applying transformations, and streaming responses:

const { fetchRouter, pipeSSE } = require('./base');
const { translateRequest } = require('../translator/index');

async function handleKiro(req, res) {
  const body = await req.text();
  const translated = translateRequest(body, 'kiro');
  const routerRes = await fetchRouter(translated, '/v1/chat/completions', req.headers);
  await pipeSSE(routerRes, res);
}

Reference the complete handler at src/mitm/handlers/kiro.js.

The Translator Registry for Format Normalization

The open-sse/translator/index.js file maintains a registry that maps OpenAI-compatible payloads to provider-native formats. Request translators live in open-sse/translator/request/ and response translators in open-sse/translator/response/.

To register a new provider, implement a pure transformation function and register it via FORMATS.registerRequest():

// open-sse/translator/request/openai-to-claude.js
import { FORMATS } from '../formats.js';

export function openAiToClaude(openaiBody) {
  return {
    prompt: openaiBody.messages[0].content,
    max_tokens: openaiBody.max_tokens
  };
}

FORMATS.registerRequest('claude', openAiToClaude);

View the registration pattern at open-sse/translator/request/openai-to-claude.js.

Follow these steps to structure complex API logic in 9router when adding new providers or endpoints.

  1. Create a minimal route file under src/app/api/v1/<feature>/route.js that imports ensureInitialized from the completions route and delegates to your handler.

  2. Build a dedicated MITM handler in src/mitm/handlers/<name>.js. Import fetchRouter and pipeSSE from ./base to handle HTTP forwarding and SSE streaming. Call translateRequest() before forwarding and translateResponse() after receiving the upstream response.

  3. Implement translator pairs if the provider uses non-OpenAI schemas. Place request normalizers in open-sse/translator/request/ and response denormalizers in open-sse/translator/response/. Each file should export a pure function and register it with the FORMATS object exported from the registry.

  4. Access shared state through stores rather than hardcoding configuration. Import providerStore from src/store/providerStore.js to retrieve provider metadata, API keys, and user-specific settings within your handlers.

Practical Code Example

Here is a complete implementation of a custom provider endpoint following 9router’s architectural conventions.

Route layer (src/app/api/v1/custom/route.js):

import { ensureInitialized } from '@/app/api/v1/chat/completions/route.js';
import { handleCustomProvider } from '@/mitm/handlers/custom.js';

export async function POST(request) {
  await ensureInitialized();
  return await handleCustomProvider(request);
}

Handler layer (src/mitm/handlers/custom.js):

const { fetchRouter, pipeSSE } = require('./base');
const { translateRequest, translateResponse } = require('../translator/index');

async function handleCustomProvider(req, res) {
  const body = await req.text();
  const upstreamBody = translateRequest(body, 'customProvider');
  const upstreamRes = await fetchRouter(upstreamBody, '/v1/chat/completions', req.headers);
  await pipeSSE(upstreamRes, res);
}

module.exports = { handleCustomProvider };

Translator layer (open-sse/translator/request/customProvider.js):

import { FORMATS } from '../formats.js';

function customProviderRequest(openaiPayload) {
  return {
    query: openaiPayload.messages.map(m => m.content).join('\n'),
    temperature: openaiPayload.temperature
  };
}

FORMATS.registerRequest('customProvider', customProviderRequest);

Summary

  • Structure complex API logic in 9router by keeping Next.js routes thin and delegating to MITM handlers.
  • Place request/response format conversions in the open-sse/translator/ registry using pure functions registered via FORMATS.registerRequest() and FORMATS.registerResponse().
  • Reuse fetchRouter and pipeSSE from src/mitm/handlers/base.js to ensure consistent proxy behavior and SSE streaming support.
  • Initialize translators once using ensureInitialized() to prevent duplicate registration side effects.
  • Store provider configuration in src/store/providerStore.js rather than hardcoding credentials or endpoints in handlers.

Frequently Asked Questions

How do I add support for a new AI provider in 9router?

Create a new request translator in open-sse/translator/request/ that maps OpenAI fields to the provider's schema, register it with FORMATS.registerRequest(), and implement a MITM handler in src/mitm/handlers/ that invokes translateRequest() before calling fetchRouter(). This keeps provider logic isolated and reusable across endpoints.

Where should I place business logic that transforms API responses?

Use the response translator pattern by adding files to open-sse/translator/response/. These modules should export pure functions that convert provider-specific payloads back to OpenAI-compatible formats, registering them via FORMATS.registerResponse() in the translator index. This separation ensures response normalization remains testable and provider-agnostic.

What is the purpose of the ensureInitialized() function?

ensureInitialized(), defined in src/app/api/v1/chat/completions/route.js, guarantees that initTranslators() runs exactly once per application instance. This prevents duplicate translator registration and ensures the FORMATS registry is fully populated before any request handling occurs, avoiding runtime errors during translation.

Can I use standard Node.js HTTP clients instead of fetchRouter?

While possible, using fetchRouter from src/mitm/handlers/base.js is recommended because it handles credential injection from providerStore, request signing, and response streaming consistently across all providers. It ensures your complex API logic integrates seamlessly with 9router's MITM architecture and supports transparent SSE piping through pipeSSE().

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →