How to Define POST Requests in 9router API Routes

In 9router, POST endpoints are defined by exporting an async POST function from a route.js file inside src/app/api/{endpoint}/, parsing the request body with await request.json(), and returning JSON responses via NextResponse.json() from next/server.

The decolua/9router repository implements a Next.js 13+ App Router architecture where API routes follow a convention-based file system. Each folder under src/app/api becomes an endpoint path, and HTTP methods are exported as async functions that handle incoming requests. To define POST requests in 9router, you create a route.js file that exports a POST handler, enabling you to build RESTful endpoints for creating providers, handling translations, and managing OAuth flows.

The 9router POST Route Convention

9router follows the Next.js App Router pattern where file paths automatically define URL routes. To create a POST endpoint, you place a route.js (or route.ts) file inside a folder hierarchy under src/app/api.

The folder structure directly maps to the endpoint URL. For example, src/app/api/providers/route.js handles requests at /api/providers. Dynamic segments use bracket notation, such as src/app/api/providers/[id]/route.js, which captures the ID as a URL parameter.

Inside the route file, you export an async function named exactly POST. Next.js dynamically wires these exports based on the HTTP verb, so only exported methods are active for that endpoint.

// src/app/api/example/route.js
import { NextResponse } from "next/server";

export async function POST(request) {
  // Handler implementation
}

Basic POST Endpoint: Echo Pattern

A minimal POST route in 9router parses the incoming JSON body and returns a response. The following example from the codebase demonstrates the essential pattern used across the application, including validation and error handling.

Create src/app/api/echo/route.js:

import { NextResponse } from "next/server";

export async function POST(request) {
  try {
    const payload = await request.json();
    if (!payload?.message) {
      return NextResponse.json(
        { error: "Missing `message` field" },
        { status: 400 }
      );
    }
    
    const result = {
      echo: payload.message,
      receivedAt: new Date().toISOString(),
    };
    
    return NextResponse.json({ success: true, result });
  } catch (e) {
    console.error("Echo POST error:", e);
    return NextResponse.json(
      { error: "Invalid JSON body" },
      { status: 400 }
    );
  }
}

This endpoint accepts POST /api/echo with a JSON body, validates the presence of a message field, and returns the echoed data with a timestamp.

Production Patterns from the 9router Codebase

The 9router source code contains several sophisticated POST handlers that demonstrate production-ready patterns for validation, database interaction, and external API calls.

Creating Provider Connections

The src/app/api/providers/route.js file implements a full-featured POST handler for creating new provider connections. It demonstrates input validation, business logic separation, and secure response handling.

import { NextResponse } from "next/server";
import {
  createProviderConnection,
} from "@/models";
import { APIKEY_PROVIDERS, WEB_COOKIE_PROVIDERS } from "@/shared/constants/config";

export async function POST(request) {
  try {
    const { provider, apiKey, name } = await request.json();

    const isWebCookie = !!WEB_COOKIE_PROVIDERS[provider];
    const valid = APIKEY_PROVIDERS[provider] || isWebCookie;
    
    if (!provider || !valid) {
      return NextResponse.json(
        { error: "Invalid provider" },
        { status: 400 }
      );
    }
    
    if (!apiKey && !isWebCookie) {
      return NextResponse.json(
        { error: "API key required" },
        { status: 400 }
      );
    }
    
    if (!name) {
      return NextResponse.json(
        { error: "Name required" },
        { status: 400 }
      );
    }

    const connection = await createProviderConnection({
      provider,
      authType: isWebCookie ? "cookie" : "apikey",
      name,
      apiKey: apiKey || "",
      isActive: true,
    });

    const result = { ...connection };
    delete result.apiKey;

    return NextResponse.json({ connection: result }, { status: 201 });
  } catch (e) {
    console.error("Provider POST error:", e);
    return NextResponse.json(
      { error: "Failed to create provider" },
      { status: 500 }
    );
  }
}

This handler validates the provider against allowed constants, checks authentication requirements, calls the createProviderConnection model helper (which wraps Prisma operations), and strips sensitive fields like apiKey before returning the response.

Multi-Step Translation Pipeline

The src/app/api/translator/translate/route.js demonstrates a complex POST handler that processes translation requests in multiple steps. It uses helper functions like parseModel and translateRequest to transform data between formats, and implements the executor pattern to abstract provider-specific logic.

import { NextResponse } from "next/server";
import { parseModel } from "open-sse/services/model.js";
import { translateRequest } from "open-sse/translator/index.js";
import { FORMATS } from "open-sse/translator/formats.js";
import { getProviderConnections } from "@/lib/localDb.js";
import { getExecutor } from "open-sse/executors/index.js";

export async function POST(request) {
  try {
    const { step, body } = await request.json();

    if (!step || !body) {
      return NextResponse.json(
        { success: false, error: "step and body required" },
        { status: 400 }
      );
    }

    if (step === 1) {
      const clientBody = body.body || body;
      const { provider, model } = parseModel(clientBody.model);
      const sourceFormat = detectFormat(clientBody);
      const targetFormat = getTargetFormat(provider);
      
      return NextResponse.json({
        success: true,
        result: { provider, model, sourceFormat, targetFormat },
      });
    }

    if (step === 2) {
      const clientBody = body.body || body;
      const { provider, model } = parseModel(clientBody.model);
      const sourceFormat = detectFormat(clientBody);
      const stream = clientBody.stream !== false;

      const interim = translateRequest(
        sourceFormat,
        FORMATS.OPENAI,
        model,
        clientBody,
        stream,
        null,
        provider
      );
      delete interim._toolNameMap;
      
      return NextResponse.json({ success: true, result: { body: interim } });
    }

    if (step === 3) {
      const openaiBody = body.body || body;
      const { provider, model } = body;
      const targetFormat = getTargetFormat(provider);
      const stream = openaiBody.stream !== false;

      const translated = translateRequest(
        FORMATS.OPENAI,
        targetFormat,
        model,
        openaiBody,
        stream,
        null,
        provider
      );
      delete translated._toolNameMap;

      const connections = await getProviderConnections({ provider });
      const connection = connections.find((c) => c.isActive !== false);
      
      if (!connection) {
        return NextResponse.json(
          { success: false, error: `No active connection for ${provider}` },
          { status: 400 }
        );
      }

      const credentials = {
        apiKey: connection.apiKey,
        accessToken: connection.accessToken,
        providerSpecificData: connection.providerSpecificData,
      };

      const executor = getExecutor(provider);
      const url = executor.buildUrl(model, stream, 0, credentials);
      const headers = executor.buildHeaders(credentials, stream);
      const finalBody = executor.transformRequest(
        model,
        translated,
        stream,
        credentials
      );

      return NextResponse.json({
        success: true,
        result: { url, headers, body: finalBody },
      });
    }

    return NextResponse.json(
      { success: false, error: "Invalid step" },
      { status: 400 }
    );
  } catch (e) {
    console.error("Translator POST error:", e);
    return NextResponse.json(
      { success: false, error: e.message },
      { status: 500 }
    );
  }
}

This pattern separates concerns by using getExecutor(provider) to obtain provider-specific logic for building URLs and headers, while the route handler remains generic and focused on orchestration.

The src/app/api/oauth/iflow/cookie/route.js file shows how to handle external API calls within POST handlers, including cookie normalization and persistence. This pattern is essential when proxying requests to third-party services, normalizing user data before sending it to external APIs, and storing the resulting connection details in the database.

Handling Request Bodies and Responses

Successful POST routes in 9router follow consistent patterns for parsing input and generating output.

  • Request Body Parsing: Use await request.json() to parse JSON payloads. For form data, use request.formData(). Malformed JSON automatically triggers errors that should be caught in your try/catch block.
  • Response Construction: Always use NextResponse.json() from next/server rather than the standard Response constructor. This ensures proper content-type headers and automatic serialization.
  • Error Handling: Wrap all logic in try/catch blocks. Log errors server-side using console.error(), but return sanitized messages to clients with appropriate HTTP status codes (400 for client errors, 500 for server errors).
  • Database Decoupling: Following the pattern seen in src/app/api/providers/route.js, delegate database operations to model helper functions like createProviderConnection or getProviderConnections. These act as thin wrappers around your ORM (Prisma), keeping routes clean and testable.

Summary

  • File Location: Create route.js files inside src/app/api/{path}/ folders, where the folder structure defines the URL endpoint.
  • Export Signature: Define POST handlers by exporting an async function named exactly POST(request) that receives the Request object.
  • Body Parsing: Extract JSON payloads using await request.json() inside the handler, with validation to ensure required fields exist.
  • Response Pattern: Return data using NextResponse.json(payload, { status }) from next/server to ensure proper headers and status codes.
  • Error Strategy: Implement comprehensive try/catch blocks, log detailed errors server-side, and return safe error messages to clients.
  • Architecture: Decouple routes from persistence layers by using model helper functions, and utilize the executor pattern for provider-specific logic abstraction.

Frequently Asked Questions

Where should I place POST route files in a 9router project?

Place your POST route files inside the src/app/api directory using the App Router convention. Create a folder for your endpoint (e.g., providers) and add a route.js file inside it. For dynamic routes, use brackets like [id] to capture URL parameters. The file system path directly maps to the API URL, so src/app/api/providers/route.js handles POST /api/providers.

How do I access the request body in a 9router POST handler?

Inside your exported POST function, call await request.json() to parse JSON bodies. For other content types, use request.text() or request.formData(). Always validate the parsed data before processing, and wrap the parsing logic in a try/catch block to handle malformed JSON gracefully by returning a 400 status response.

Wrap your entire handler implementation in a try/catch block. Log the detailed error using console.error() for server-side debugging, then return a JSON response using NextResponse.json({ error: "message" }, { status: 500 }) for server errors or status 400 for client errors. This pattern, demonstrated in src/app/api/providers/route.js, ensures the API remains stable and returns predictable error formats to clients.

Can I use dynamic routes with POST handlers in 9router?

Yes, dynamic routes work with POST handlers by creating folders with bracket notation (e.g., [id]) inside your API directory. For example, src/app/api/providers/[id]/route.js handles POST requests to /api/providers/123, where the ID becomes a URL parameter accessible within your handler.

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 →