Backend Development
Build plugin backends with Express, Prisma, and the NaaP backend SDK.
Overview#
Plugin backends are Express.js servers that provide API endpoints. They use Prisma for database access and can optionally use the @naap/plugin-server-sdk for platform integration.
The server SDK includes built-in middleware for common patterns:
- Auth — JWT token validation and user extraction
- CORS — Pre-configured with all NaaP custom headers
- External API Proxy — Safely proxy requests to third-party APIs (see guide)
Project Structure#
| 1 | backend/ |
| 2 | ├── src/ |
| 3 | │ ├── server.ts # Express entry point |
| 4 | │ ├── db/ |
| 5 | │ │ └── client.ts # Re-exports prisma from @naap/database |
| 6 | │ ├── routes/ |
| 7 | │ │ ├── index.ts # Route aggregator |
| 8 | │ │ └── tasks.ts # Feature routes |
| 9 | │ └── middleware/ |
| 10 | │ └── auth.ts # Auth middleware |
| 11 | ├── .env # DATABASE_URL=...localhost:5432/naap |
| 12 | ├── tsconfig.json |
| 13 | └── package.json # Depends on @naap/database (NOT @prisma/client) |
Note: There is NO
prisma/directory in plugin backends. All database models are defined centrally inpackages/database/prisma/schema.prisma. See Database Architecture.
Express Server Setup#
| 1 | // backend/src/server.ts |
| 2 | import express from 'express'; |
| 3 | import cors from 'cors'; |
| 4 | import { taskRoutes } from './routes/tasks.js'; |
| 5 | |
| 6 | const app = express(); |
| 7 | const PORT = process.env.PORT || 4020; |
| 8 | |
| 9 | // Middleware |
| 10 | app.use(cors()); |
| 11 | app.use(express.json()); |
| 12 | |
| 13 | // Health check (required by the shell) |
| 14 | app.get('/healthz', (req, res) => { |
| 15 | res.json({ |
| 16 | status: 'healthy', |
| 17 | service: 'my-plugin', |
| 18 | timestamp: new Date().toISOString(), |
| 19 | }); |
| 20 | }); |
| 21 | |
| 22 | // Routes |
| 23 | app.use('/api/v1/my-plugin', taskRoutes); |
| 24 | |
| 25 | // Error handler |
| 26 | app.use((err, req, res, next) => { |
| 27 | console.error('[my-plugin]', err); |
| 28 | res.status(500).json({ |
| 29 | error: 'Internal Server Error', |
| 30 | message: process.env.NODE_ENV === 'development' ? err.message : undefined, |
| 31 | }); |
| 32 | }); |
| 33 | |
| 34 | app.listen(PORT, () => { |
| 35 | console.log(`My Plugin backend running on port ${PORT}`); |
| 36 | }); |
Database with Prisma (Unified Architecture)#
NaaP uses a single PostgreSQL database with multi-schema isolation. All models live in packages/database/prisma/schema.prisma — never in plugin directories. See Database Architecture for the full rules.
Adding Models for Your Plugin#
Add models to the unified schema at packages/database/prisma/schema.prisma:
| 1 | // packages/database/prisma/schema.prisma (append your models here) |
| 2 | model MyPluginItem { |
| 3 | id String @id @default(uuid()) |
| 4 | name String |
| 5 | description String? |
| 6 | status String @default("active") |
| 7 | userId String |
| 8 | teamId String? |
| 9 | metadata Json? |
| 10 | createdAt DateTime @default(now()) |
| 11 | updatedAt DateTime @updatedAt |
| 12 | |
| 13 | @@index([userId]) |
| 14 | @@index([teamId]) |
| 15 | @@schema("plugin_my_plugin") // ← MANDATORY |
| 16 | } |
Key requirements:
- Every model MUST have
@@schema("plugin_<name>") - Every model MUST be prefixed (e.g.,
MyPluginItem, notItem) - Enums also need
@@schema()annotations
Schema Management#
All schema operations run from the central packages/database directory:
Database Client#
Every plugin uses the same one-liner:
// backend/src/db/client.ts
import { prisma } from '@naap/database';
export const db = prisma;Do NOT:
- Import from
@prisma/clientor localgenerated/directories - Call
new PrismaClient() - Create a
prisma/schema.prismain your plugin
API Routes#
| 1 | // backend/src/routes/tasks.ts |
| 2 | import { Router } from 'express'; |
| 3 | import { prisma } from '../db/client.js'; |
| 4 | |
| 5 | export const taskRoutes = Router(); |
| 6 | |
| 7 | // List items with pagination |
| 8 | taskRoutes.get('/items', async (req, res) => { |
| 9 | const { page = '1', limit = '20', status } = req.query; |
| 10 | const skip = (parseInt(page as string) - 1) * parseInt(limit as string); |
| 11 | |
| 12 | const [items, total] = await Promise.all([ |
| 13 | prisma.item.findMany({ |
| 14 | where: status ? { status: status as string } : {}, |
| 15 | skip, |
| 16 | take: parseInt(limit as string), |
| 17 | orderBy: { createdAt: 'desc' }, |
| 18 | }), |
| 19 | prisma.item.count({ |
| 20 | where: status ? { status: status as string } : {}, |
| 21 | }), |
| 22 | ]); |
| 23 | |
| 24 | // Standard API envelope: { success, data, meta? } |
| 25 | res.json({ |
| 26 | success: true, |
| 27 | data: { items }, |
| 28 | meta: { page: parseInt(page as string), limit: parseInt(limit as string), total }, |
| 29 | }); |
| 30 | }); |
| 31 | |
| 32 | // Create item |
| 33 | taskRoutes.post('/items', async (req, res) => { |
| 34 | const userId = req.headers['x-user-id'] as string; |
| 35 | const teamId = req.headers['x-team-id'] as string | undefined; |
| 36 | |
| 37 | const item = await prisma.item.create({ |
| 38 | data: { |
| 39 | ...req.body, |
| 40 | userId, |
| 41 | teamId, |
| 42 | }, |
| 43 | }); |
| 44 | |
| 45 | res.status(201).json({ success: true, data: { item } }); |
| 46 | }); |
| 47 | |
| 48 | // Get single item |
| 49 | taskRoutes.get('/items/:id', async (req, res) => { |
| 50 | const item = await prisma.item.findUnique({ |
| 51 | where: { id: req.params.id }, |
| 52 | }); |
| 53 | |
| 54 | if (!item) { |
| 55 | return res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Item not found' } }); |
| 56 | } |
| 57 | |
| 58 | res.json({ success: true, data: { item } }); |
| 59 | }); |
| 60 | |
| 61 | // Update item |
| 62 | taskRoutes.patch('/items/:id', async (req, res) => { |
| 63 | const item = await prisma.item.update({ |
| 64 | where: { id: req.params.id }, |
| 65 | data: req.body, |
| 66 | }); |
| 67 | |
| 68 | res.json({ success: true, data: { item } }); |
| 69 | }); |
| 70 | |
| 71 | // Delete item |
| 72 | taskRoutes.delete('/items/:id', async (req, res) => { |
| 73 | await prisma.item.delete({ |
| 74 | where: { id: req.params.id }, |
| 75 | }); |
| 76 | |
| 77 | res.json({ success: true, data: null }); |
| 78 | }); |
Authentication Headers#
The shell proxy forwards authentication headers to your backend:
| Header | Description |
|---|---|
x-user-id | Current user's ID |
x-user-email | Current user's email |
x-team-id | Current team's ID (if applicable) |
x-request-id | Unique request identifier |
authorization | Bearer token |
Seed Data#
Seed scripts use the unified client from @naap/database:
| 1 | // packages/database/prisma/seed.ts (add your seed data here) |
| 2 | import { prisma } from '../src/index'; |
| 3 | |
| 4 | async function main() { |
| 5 | await prisma.myPluginItem.createMany({ |
| 6 | data: [ |
| 7 | { name: 'Sample Item 1', userId: 'system', status: 'active' }, |
| 8 | { name: 'Sample Item 2', userId: 'system', status: 'active' }, |
| 9 | ], |
| 10 | }); |
| 11 | console.log('Seed data created'); |
| 12 | } |
| 13 | |
| 14 | main() |
| 15 | .catch(console.error) |
| 16 | .finally(() => prisma.$disconnect()); |
Run the seed from packages/database: