How to add a new API endpoint to the Express server: file layout, auth, request handling, file storage, and mounting.
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.
Create server/routes/<name>.ts. All route files follow the same skeleton:
import { Router } from 'express';
import { requireAuth } from '../middleware/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.
The session is typed via a declare module in
server/middleware/auth.ts. The available fields are
req.session.userId and req.session.username, both
string | undefined. After requireAuth they are
guaranteed to be set — use the non-null assertion (!) freely:
const userId = req.session.userId!;
const username = req.session.username!;
Both body parsers are applied globally in server/index.ts
— no per-route setup is needed:
Content-Type: application/json) — available as req.body, cast to your type: req.body as { field: string }Content-Type: text/plain) — available as req.body (a string). Used for raw file content saves.req.query.paramName as stringreq.params.paramName
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, '_' ).
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; }
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.
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.