Writing Backend Routes

How to add a new API endpoint to the Express server: file layout, auth, request handling, file storage, and mounting.

Summary

Every API feature lives in its own router file under server/routes/. The router is created with Express's Router(), handlers are attached to it, and it is exported and mounted in server/index.ts at an /api/<name> path. The server runs via ts-node — there is no separate compilation step for server-side TypeScript. See server/routes/layout.ts (simple JSON CRUD) and server/routes/rojos.ts (streaming) as reference implementations.

Steps

1 — Create the route file

Create server/routes/<name>.ts. All route files follow the same skeleton:

import { Router } from 'express';
import { requireAuth } from 'auth-connector/server/auth';

const router = Router();
router.use( requireAuth );

router.get( '/', ( req, res ) =>
{
  res.json( { ok: true } );
} );

export default router;

requireAuth is applied with router.use so it covers every handler in the file. If only some routes need auth, apply it per-handler as a middleware argument instead.

2 — Access the authenticated user

req.auth is typed via a declare module in source/auth-connector/source/server/auth.ts. The available fields are userId, email, roles, and products. After requireAuth they are guaranteed to be set — use the non-null assertion (!) freely:

const userId = req.auth!.userId;
const email  = req.auth!.email;

3 — Read request data

Both body parsers are applied globally in server/index.ts — no per-route setup is needed:

  • JSON body (Content-Type: application/json) — available as req.body, cast to your type: req.body as { field: string }
  • Plain text body (Content-Type: text/plain) — available as req.body (a string). Used for raw file content saves.
  • Query paramsreq.query.paramName as string
  • Route paramsreq.params.paramName

4 — Store data as JSON files

There is no database. Persistent data goes in subdirectories of storage/. Reference the path relative to the compiled output location using __dirname:

import fs from 'fs';
import path from 'path';

const DATA_DIR = path.join( __dirname, '..', '..', 'storage', 'my-feature' );

function ensureDir(): void
{
  if ( !fs.existsSync( DATA_DIR ) ) fs.mkdirSync( DATA_DIR, { recursive: true } );
}

function readData( id: string ): any
{
  ensureDir();
  const fp = path.join( DATA_DIR, id + '.json' );
  if ( !fs.existsSync( fp ) ) return null;
  return JSON.parse( fs.readFileSync( fp, 'utf8' ) );
}

function writeData( id: string, data: any ): void
{
  ensureDir();
  fs.writeFileSync( path.join( DATA_DIR, id + '.json' ), JSON.stringify( data ), 'utf8' );
}

IDs should be UUIDs from crypto.randomUUID(), never user-supplied strings used directly as filenames. If you must derive a filename from user input, sanitise it: s.replace( /[^a-zA-Z0-9_-]/g, '_' ).

5 — Return responses

Standard patterns used across the codebase:

res.json( { ok: true } );                         // success, no payload
res.json( data );                                   // success with payload
res.status( 400 ).json( { error: 'Bad input' } );  // client error
res.status( 401 ).json( { error: 'Not authenticated' } ); // handled by requireAuth
res.status( 404 ).json( { error: 'Not found' } );
res.status( 500 ).json( { error: String( err ) } );

Always return after sending a response inside a conditional block to prevent Express from throwing "headers already sent":

if ( !id ) { res.status( 400 ).json( { error: 'Missing id' } ); return; }

6 — Streaming responses

For SSE / streaming (e.g. AI output), set the headers explicitly, flush them, write newline-delimited JSON chunks, then end the response:

res.setHeader( 'Content-Type', 'text/event-stream' );
res.setHeader( 'Cache-Control', 'no-cache' );
res.setHeader( 'Connection', 'keep-alive' );
res.flushHeaders();

for await ( const chunk of someStream )
{
  res.write( JSON.stringify( { type: 'DATA', value: chunk } ) + '\n' );
}

res.write( JSON.stringify( { type: 'DONE' } ) + '\n' );
res.end();

Wrap the whole block in try/catch and check !res.headersSent before sending an error response, since headers may already be flushed by the time an exception occurs.

7 — Mount in server/index.ts

Open server/index.ts and add two lines — an import and a app.use call — following the existing pattern:

import myRouter from './routes/my-feature';

app.use( '/api/my-feature', myRouter );

Restart the server after this change. No build step is needed — ts-node compiles on the fly.