2026-07-11 12:24:17 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > Writing Backend Routes — Roject< / title >
< link rel = "stylesheet" href = "../../_assets_/styles.css" >
< link rel = "stylesheet" href = "../../_assets_/nav.css" >
< / head >
< body >
< div class = "page" >
< header >
< h1 > Writing Backend Routes< / h1 >
< p class = "subtitle" > How to add a new API endpoint to the Express server: file layout, auth, request handling, file storage, and mounting.< / p >
< / header >
< section >
< h2 > Summary< / h2 >
< div class = "card" >
< p >
Every API feature lives in its own router file under < code > server/routes/< / code > .
The router is created with Express's < code > Router()< / code > , handlers are attached
to it, and it is exported and mounted in < code > server/index.ts< / code > at an
< code > /api/< name> < / code > path. The server runs via < code > ts-node< / code >
— there is no separate compilation step for server-side TypeScript.
See < code > server/routes/layout.ts< / code > (simple JSON CRUD) and
< code > server/routes/rojos.ts< / code > (streaming) as reference implementations.
< / p >
< / div >
< / section >
< section >
< h2 > Steps< / h2 >
< div class = "card" >
< h3 > 1 — Create the route file< / h3 >
< p > Create < code > server/routes/< name> .ts< / code > . All route files follow the same skeleton:< / p >
< pre > < code > import { Router } from 'express';
2026-07-17 11:37:48 +00:00
import { requireAuth } from 'auth-connector/server/auth';
2026-07-11 12:24:17 +00:00
const router = Router();
router.use( requireAuth );
router.get( '/', ( req, res ) =>
{
res.json( { ok: true } );
} );
export default router;< / code > < / pre >
< p style = "margin-top:0.75rem" >
< code > requireAuth< / code > is applied with < code > router.use< / code > so it covers
every handler in the file. If only some routes need auth, apply it per-handler
as a middleware argument instead.
< / p >
< / div >
< div class = "card" >
2026-07-17 11:37:48 +00:00
< h3 > 2 — Access the authenticated user< / h3 >
2026-07-11 12:24:17 +00:00
< p >
2026-07-17 11:37:48 +00:00
< code > req.auth< / code > is typed via a < code > declare module< / code > in
< code > source/auth-connector/source/server/auth.ts< / code > . The available fields are
< code > userId< / code > , < code > email< / code > , < code > roles< / code > , and < code > products< / code > .
After < code > requireAuth< / code > they are guaranteed to be set — use the non-null
assertion (< code > !< / code > ) freely:
2026-07-11 12:24:17 +00:00
< / p >
2026-07-17 11:37:48 +00:00
< pre > < code > const userId = req.auth!.userId;
const email = req.auth!.email;< / code > < / pre >
2026-07-11 12:24:17 +00:00
< / div >
< div class = "card" >
< h3 > 3 — Read request data< / h3 >
< p >
Both body parsers are applied globally in < code > server/index.ts< / code >
— no per-route setup is needed:
< / p >
< ul style = "margin-top:0.5rem" >
< li > < strong > JSON body< / strong > (< code > Content-Type: application/json< / code > ) — available as < code > req.body< / code > , cast to your type: < code > req.body as { field: string } < / code > < / li >
< li > < strong > Plain text body< / strong > (< code > Content-Type: text/plain< / code > ) — available as < code > req.body< / code > (a string). Used for raw file content saves.< / li >
< li > < strong > Query params< / strong > — < code > req.query.paramName as string< / code > < / li >
< li > < strong > Route params< / strong > — < code > req.params.paramName< / code > < / li >
< / ul >
< / div >
< div class = "card" >
< h3 > 4 — Store data as JSON files< / h3 >
< p >
There is no database. Persistent data goes in subdirectories of
< code > storage/< / code > . Reference the path relative to the compiled output
location using < code > __dirname< / code > :
< / p >
< pre > < code > 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' );
}< / code > < / pre >
< p style = "margin-top:0.75rem" >
IDs should be UUIDs from < code > crypto.randomUUID()< / code > , never user-supplied
strings used directly as filenames. If you must derive a filename from user
input, sanitise it: < code > s.replace( /[^a-zA-Z0-9_-]/g, '_' )< / code > .
< / p >
< / div >
< div class = "card" >
< h3 > 5 — Return responses< / h3 >
< p > Standard patterns used across the codebase:< / p >
< pre > < code > 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 ) } );< / code > < / pre >
< p style = "margin-top:0.75rem" >
Always < code > return< / code > after sending a response inside a conditional block
to prevent Express from throwing "headers already sent":
< / p >
< pre > < code > if ( !id ) { res.status( 400 ).json( { error: 'Missing id' } ); return; }< / code > < / pre >
< / div >
< div class = "card" >
< h3 > 6 — Streaming responses< / h3 >
< p >
For SSE / streaming (e.g. AI output), set the headers explicitly, flush them,
write newline-delimited JSON chunks, then end the response:
< / p >
< pre > < code > 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();< / code > < / pre >
< p style = "margin-top:0.75rem" >
Wrap the whole block in < code > try/catch< / code > and check
< code > !res.headersSent< / code > before sending an error response, since headers
may already be flushed by the time an exception occurs.
< / p >
< / div >
< div class = "card" >
< h3 > 7 — Mount in server/index.ts< / h3 >
< p >
Open < code > server/index.ts< / code > and add two lines — an import and a
< code > app.use< / code > call — following the existing pattern:
< / p >
< pre > < code > import myRouter from './routes/my-feature';
app.use( '/api/my-feature', myRouter );< / code > < / pre >
< p style = "margin-top:0.75rem" >
Restart the server after this change. No build step is needed —
< code > ts-node< / code > compiles on the fly.
< / p >
< / div >
< / section >
< footer >
Roject — writing backend routes
< / footer >
< / div >
< script > var NAV _ROOT = '../../' ; < / script >
< script src = "../../_assets_/nav-data.js" > < / script >
< script src = "../../_assets_/nav.js" > < / script >
< / body >
< / html >