A visual workflow automation platform — similar to n8n — built with Next.js 15 on the frontend and a custom Node.js + Express API server on the backend. Design, save, and execute multi-step workflows that chain together AI models, HTTP requests, messaging platforms, and external trigger sources like Google Forms and Stripe, all with real-time execution status visualised directly in the editor.
- Architecture Overview
- Tech Stack
- Repository Structure
- Backend — Setup & Configuration
- Frontend — Setup & Configuration
- Inngest Dev Server
- Webhooks (Local Development)
- API Reference
- Workflow Engine
- Node Types & Executors
- Real-Time Execution Status (SSE)
- Authentication
- Credential Encryption
- Key Architectural Decisions
- What Was Removed
┌──────────────────────────────────────────────┐
│ Next.js Frontend (port 3000) │
│ │
│ React 19 · Jotai · ky · @xyflow/react │
│ shadcn/ui · react-hook-form · zod │
│ │
│ API Layer: src/api/{client,auth,workflows, │
│ credentials}.ts │
│ │
│ SSE streams ──────────────────────────────► │
└──────────────────┬───────────────────────────┘
│ HTTP + SSE (cookie auth)
▼
┌──────────────────────────────────────────────┐
│ Express Backend (port 4000) │
│ │
│ 7 REST modules: auth, users, workflows, │
│ nodes, connections, credentials, executions │
│ + webhooks (no auth) │
│ │
│ Middleware: JWT cookies, Helmet, CORS, │
│ rate-limit, Zod validation, request logger │
└──────────────┬───────────────────────────────┘
│
┌───────┴────────┐
▼ ▼
┌─────────────┐ ┌─────────────────────────────┐
│ PostgreSQL │ │ Inngest │
│ (NeonDB) │ │ │
│ │ │ Durable background jobs │
│ 12 SQL │ │ execute-workflow function │
│ migrations │ │ 9 node executors │
│ RLS + AES │ │ SSE events pushed back │
│ encryption │ │ to Express → browser │
└─────────────┘ └─────────────────────────────┘
| Layer | Technology |
|---|---|
| Framework | Next.js 15.5.4 (App Router, Turbopack) |
| UI Library | React 19 |
| State Management | Jotai 2 |
| HTTP Client | ky 1 |
| Flow Editor | @xyflow/react 12 |
| Forms | react-hook-form 7 + zod 4 |
| UI Components | shadcn/ui (Radix primitives + Tailwind CSS 4) |
| Icons | lucide-react |
| Notifications | sonner |
| Linting/Formatting | Biome 2 |
| Layer | Technology |
|---|---|
| Runtime | Node.js (ESM, "type": "module") |
| Framework | Express 5 |
| Database | PostgreSQL via pg Pool (NeonDB, always-on SSL) |
| Auth | JWT (jsonwebtoken) stored in httpOnly cookies |
| Password Hashing | bcryptjs |
| Encryption | AES-256-GCM via Node.js node:crypto |
| Workflow Engine | Inngest (durable steps, local dev server) |
| AI SDK | Vercel AI SDK (ai, @ai-sdk/openai, @ai-sdk/google, @ai-sdk/anthropic) |
| Templating | Handlebars (with custom json, encodeURI, eq helpers) |
| Graph Ordering | toposort |
| ID Generation | uuid v4 |
| Validation | zod 4 |
| Security | helmet, cors, express-rate-limit |
| Logging | Custom structured JSON logger |
nodebase/
├── package.json # Frontend dependencies
├── next.config.ts
├── tsconfig.json
├── biome.json
├── README.MD
├── Server_Side_Integrations.MD
│
├── nodebase_backend/ # Express API server (separate project)
│ ├── package.json
│ ├── .env # Backend env vars (not committed)
│ └── src/
│ ├── server.js # Entry point — starts on PORT (default 4000)
│ ├── app.js # Express app, middleware, routes
│ ├── config/
│ │ ├── env.js # Validates + exports all env vars
│ │ ├── db.js # pg Pool (SSL always-on for NeonDB)
│ │ └── constants.js # NodeType + ExecutionStatus enums
│ ├── middleware/
│ │ ├── auth.js # verifyToken — JWT cookie middleware
│ │ ├── validate.js # Zod schema request validation
│ │ ├── error-handler.js # Global error handler
│ │ ├── not-found.js # 404 handler
│ │ └── request-logger.js # HTTP request/response logger
│ ├── modules/
│ │ ├── auth/ # register, login, logout, me
│ │ ├── users/ # user profile CRUD
│ │ ├── workflows/ # workflow CRUD + execute + SSE stream
│ │ ├── nodes/ # node CRUD per workflow
│ │ ├── connections/ # connection (edge) CRUD per workflow
│ │ ├── credentials/ # encrypted credential CRUD
│ │ ├── executions/ # execution history + SSE stream
│ │ │ └── sse.js # SSEManager (execution + workflow level)
│ │ └── webhooks/ # Google Form + Stripe inbound webhooks
│ ├── engine/
│ │ ├── runner.js # Workflow orchestrator
│ │ ├── toposort.js # DAG topological sort
│ │ └── template.js # Handlebars template resolution
│ ├── inngest/
│ │ ├── client.js # Inngest client (isDev in development)
│ │ ├── serve.js # inngest/express serve middleware
│ │ ├── functions/
│ │ │ └── execute-workflow.js
│ │ └── executors/ # 9 node executors (one per node type)
│ ├── scripts/
│ │ ├── migrate.js # Migration runner
│ │ ├── 000_create_extensions.sql
│ │ ├── 001_create_enums.sql
│ │ ├── 002_create_users.sql
│ │ ├── 003_create_sessions.sql
│ │ ├── 004_create_accounts.sql
│ │ ├── 005_create_verifications.sql
│ │ ├── 006_create_credentials.sql
│ │ ├── 007_create_workflows.sql
│ │ ├── 008_create_nodes.sql
│ │ ├── 009_create_connections.sql
│ │ ├── 010_create_executions.sql
│ │ └── 011_create_node_executions.sql
│ └── utils/
│ ├── api-response.js # Standardised JSON response helper
│ ├── api-error.js # AppError class with status code
│ ├── catch-async.js # Wraps async controllers
│ ├── encryption.js # AES-256-GCM encrypt/decrypt
│ ├── logger.js # Structured JSON logger (stdout)
│ └── transaction.js # withTransaction() + RLS SET LOCAL
│
└── src/
├── api/ # Frontend HTTP client layer
│ ├── client.ts # ky instance (NEXT_PUBLIC_API_URL, cookies)
│ ├── auth.ts # login, register, logout, getMe
│ ├── workflows.ts # workflow CRUD + execute + save
│ └── credentials.ts # credential CRUD + getByType
├── app/
│ ├── layout.tsx # AuthProvider wrapper
│ ├── (auth)/ # login + signup pages
│ └── (dashboard)/
│ ├── (editor)/workflows/[workflowId]/page.tsx
│ └── (rest)/ # workflows, credentials, executions pages
├── components/
│ ├── react-flow/
│ │ ├── base-node.tsx # status icons (checkmark/X/spinner)
│ │ ├── base-handle.tsx
│ │ ├── node-status-indicator.tsx # border colors + loading spinner
│ │ └── placeholder-node.tsx
│ └── ui/ # shadcn/ui components
├── config/
│ ├── constants.ts # NodeType + ExecutionStatus enums (frontend)
│ └── node-components.ts # nodeComponents map for React Flow
├── features/
│ ├── auth/
│ │ ├── components/ # login-form, register-form (eye toggle)
│ │ ├── store/auth-atoms.ts # currentUserAtom, isAuthenticatedAtom
│ │ └── components/auth-provider.tsx
│ ├── credentials/
│ │ ├── store/credential-atoms.ts
│ │ ├── hooks/use-credentials.ts
│ │ └── components/ # credential + credentials UI
│ ├── editor/
│ │ ├── components/
│ │ │ ├── editor.tsx # React Flow + workflow SSE subscription
│ │ │ ├── editor-header.tsx
│ │ │ └── execute-workflow-button.tsx
│ │ ├── hooks/
│ │ │ ├── use-execution-stream.ts # SSE connect + subscribeToWorkflow
│ │ │ └── use-node-status.ts # per-node status from atom
│ │ └── store/
│ │ ├── atoms.ts # editorAtom (ReactFlowInstance)
│ │ └── execution-atoms.ts # nodeStatusMapAtom, activeExecutionIdAtom
│ ├── executions/
│ │ ├── hooks/use-executions.ts
│ │ └── components/ # execution + executions UI + all 6 execution nodes
│ ├── triggers/
│ │ └── components/ # 3 trigger nodes (manual, google-form, stripe)
│ ├── subscriptions/
│ └── workflows/
│ ├── store/workflow-atoms.ts
│ ├── hooks/use-workflows.ts
│ └── components/
└── hooks/
├── use-entity-search.tsx
├── use-mobile.ts
└── use-upgrade-modal.tsx
Create nodebase_backend/.env:
# Server
PORT=4000
NODE_ENV=development
# PostgreSQL (NeonDB or any Postgres — SSL is always enabled)
DATABASE_URL=postgresql://USER:PASSWORD@HOST/DATABASE?sslmode=require
# JWT — used for httpOnly cookie auth
JWT_SECRET=your-long-random-secret-here
JWT_EXPIRES_IN=7d
# Credential Encryption — must be exactly 32 bytes (64 hex chars)
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
ENCRYPTION_KEY=a1b2c3d4e5f6...64hexchars...
# CORS — frontend origin
CLIENT_URL=http://localhost:3000
# Inngest — only required in production; in development isDev:true is used automatically
INNGEST_EVENT_KEY=your-inngest-event-keyImportant:
ENCRYPTION_KEYmust be a 64-character hex string (32 bytes). Changing it after credentials have been stored will make those credentials unreadable.
Production:
| Package | Version | Purpose |
|---|---|---|
express |
^5.2.1 | HTTP framework |
pg |
^8.19.0 | PostgreSQL client (connection pool) |
jsonwebtoken |
^9.0.3 | JWT signing/verification |
bcryptjs |
^3.0.3 | Password hashing (10 rounds) |
cookie-parser |
^1.4.7 | Parse httpOnly JWT cookie |
cors |
^2.8.6 | Cross-origin resource sharing |
helmet |
^8.1.0 | Security HTTP headers |
express-rate-limit |
^8.2.1 | Rate limiting per IP |
dotenv |
^17.3.1 | Load .env file |
zod |
^4.3.6 | Request body/params validation |
uuid |
^13.0.0 | UUID v4 primary key generation |
inngest |
^3.52.4 | Durable workflow execution engine |
ai |
^6.0.105 | Vercel AI SDK core |
@ai-sdk/openai |
^3.0.37 | OpenAI provider (gpt-4o-mini) |
@ai-sdk/google |
^3.0.34 | Google provider (gemini-2.5-flash) |
@ai-sdk/anthropic |
^3.0.50 | Anthropic provider (claude-sonnet-4) |
handlebars |
^4.7.8 | Template resolution ({{nodes.id.output.text}}) |
html-entities |
^2.6.0 | Decode HTML entities after Handlebars rendering |
toposort |
^2.0.2 | Topological sort for DAG execution order |
Development:
| Package | Version | Purpose |
|---|---|---|
nodemon |
^3.1.14 | Auto-restart on file changes |
All migrations are plain SQL files run in numeric order. The migration runner tracks which have been applied in a _migrations table.
cd nodebase_backend
npm run db:migrate| Migration | Creates |
|---|---|
000_create_extensions |
pgcrypto, uuid-ossp extensions |
001_create_enums |
node_type enum (INITIAL, MANUAL_TRIGGER, HTTP_REQUEST, GOOGLE_FORM_TRIGGER, STRIPE_TRIGGER, ANTHROPIC, GEMINI, OPENAI, DISCORD, SLACK), execution_status enum (PENDING, RUNNING, SUCCESS, FAILED, CANCELLED) |
002_create_users |
user table (id TEXT PK, email, name, password_hash, timestamps) with RLS |
003_create_sessions |
session table for session tracking |
004_create_accounts |
account table for OAuth accounts |
005_create_verifications |
verification table |
006_create_credentials |
credential table (id TEXT PK, user_id FK, name, type, encrypted_value, iv, auth_tag) with RLS |
007_create_workflows |
workflow table (id TEXT PK, user_id FK, name, timestamps) with RLS |
008_create_nodes |
node table (id TEXT PK, workflow_id FK, type node_type, data JSONB, position_x, position_y) with RLS |
009_create_connections |
connection table (id TEXT PK, workflow_id FK, source_node_id, target_node_id, source_handle, target_handle) with RLS |
010_create_executions |
execution table (id TEXT PK, user_id FK, workflow_id FK, status execution_status, result JSONB, error TEXT, timestamps) with RLS |
011_create_node_executions |
node_execution table (id TEXT PK, execution_id FK → TEXT, node_id FK → TEXT, status, output JSONB, error TEXT, timestamps) with RLS using ::text casts |
Note on RLS: All tables use PostgreSQL Row-Level Security. Every request runs
SET LOCAL app.current_user_id = '...'viaclient.escapeLiteral()(parameterized queries are not supported by PostgresSET LOCAL) inside a transaction, ensuring users can only access their own data.
cd nodebase_backend
npm install
cp .env.example .env # fill in your values
npm run db:migrate # run all 12 migrations
npm run dev # starts nodemon on PORT (default 4000)Create .env.local in the root nodebase/ directory:
# Backend API base URL
NEXT_PUBLIC_API_URL=http://localhost:4000
# Webhook URL for trigger nodes (use ngrok tunnel in local dev)
# This is displayed inside the Google Form / Stripe trigger dialogs
# so users know where to point their external service.
NEXT_PUBLIC_WEBHOOK_URL=https://your-ngrok-subdomain.ngrok-free.appKey additions and their roles:
| Package | Version | Purpose |
|---|---|---|
ky |
^1.12.0 | Lightweight HTTP client replacing tRPC. Configured with credentials: "include" for cookie auth |
jotai |
^2.15.0 | Atomic state management for auth, workflows, credentials, and real-time execution status |
@xyflow/react |
^12.8.6 | React Flow — visual node graph editor |
react-hook-form |
^7.64.0 | Form state management for all dialogs |
zod |
^4.1.11 | Frontend schema validation (mirrors backend) |
sonner |
^2.0.7 | Toast notifications |
next |
15.5.4 | App Router with Turbopack |
react / react-dom |
19.1.0 | React 19 |
lucide-react |
^0.544.0 | All icons including Eye/EyeOff for password toggles |
@tanstack/react-query |
^5.90.2 | Server state management (retained from original) |
cd nodebase # root of the repo
npm install
cp .env.local.example .env.local # fill in values
npm run dev # starts Next.js on port 3000 (Turbopack)Inngest is used as the durable workflow execution engine. In development, you need the Inngest Dev Server running alongside the backend so it can receive and dispatch workflow events.
cd nodebase_backend
npm run inngest:dev
# runs: npx inngest-cli@latest dev -u http://localhost:4000/api/inngestThe -u flag tells Inngest exactly where your serve endpoint is, preventing it from probing random paths on your frontend (which causes 404 noise in the browser console).
The Inngest dashboard is available at http://localhost:8288 — you can inspect events, replay failed functions, and see step-by-step execution traces there.
How Inngest is used:
POST /api/workflows/:id/execute(or a webhook) creates an execution row and firesworkflow/executeevent- The Inngest function picks it up, loads the workflow graph + credentials, and runs the engine
- Each node execution is a durable step — if the process crashes, Inngest resumes from the last completed step
- The function emits SSE events back to Express via
sseManageras each node runs
Google Form and Stripe triggers require an externally reachable URL. Use ngrok to tunnel your local backend:
ngrok http 4000
# e.g. output: https://ideographic-arie-hastily.ngrok-free.appThen set in .env.local:
NEXT_PUBLIC_WEBHOOK_URL=https://ideographic-arie-hastily.ngrok-free.appThis URL is shown inside the Google Form Trigger and Stripe Trigger node configuration dialogs so you can copy it directly into the external service.
Webhook endpoints (no authentication required):
| Trigger | Method | URL |
|---|---|---|
| Google Form | POST | http://localhost:4000/api/webhooks/google-form?workflowId=<id> |
| Stripe | POST | http://localhost:4000/api/webhooks/stripe?workflowId=<id> |
Both endpoints:
- Look up the workflow and its owner
- Create an
executionrow with statusRUNNING - Fire the
workflow/executeInngest event with the trigger payload - Return
202 Acceptedimmediately
All routes are prefixed with /api. All routes except /api/auth/* and /api/webhooks/* require a valid JWT cookie (Authorization via httpOnly cookie set at login).
| Method | Path | Description |
|---|---|---|
| POST | /register |
Create account. Body: { name, email, password } |
| POST | /login |
Sign in. Sets token httpOnly cookie. Body: { email, password } |
| POST | /logout |
Clear auth cookie |
| GET | /me |
Return current authenticated user |
| Method | Path | Description |
|---|---|---|
| GET | / |
List workflows with pagination (?page=1&pageSize=10) |
| POST | / |
Create workflow. Body: { name } |
| GET | /:id |
Get workflow with nodes and connections |
| PATCH | /:id/name |
Rename workflow. Body: { name } |
| PUT | /:id |
Save full workflow (delete-and-reinsert nodes/connections). Body: { nodes[], edges[] } |
| DELETE | /:id |
Delete workflow |
| POST | /:id/execute |
Start execution (creates execution row + fires Inngest event) |
| GET | /:id/stream |
SSE stream — notifies open editors when any execution starts on this workflow |
| Method | Path | Description |
|---|---|---|
| GET | / |
List credentials (encrypted values never returned) |
| POST | / |
Create credential. Body: { name, type, value } |
| GET | /:id |
Get credential metadata |
| PATCH | /:id |
Update credential. Body: { name?, value? } |
| DELETE | /:id |
Delete credential |
| GET | /type/:type |
Get credentials by type (e.g. OPENAI, GEMINI, ANTHROPIC) |
| Method | Path | Description |
|---|---|---|
| GET | / |
List executions with pagination |
| GET | /:id |
Get execution details |
| GET | /:id/nodes |
Get per-node execution results |
| GET | /:id/stream |
SSE stream — real-time node status events for one execution |
| Method | Path | Description |
|---|---|---|
| POST | /google-form?workflowId= |
Trigger workflow execution from Google Form submission |
| POST | /stripe?workflowId= |
Trigger workflow execution from Stripe event |
Full CRUD used internally by the workflow save/load flow.
The engine (src/engine/) runs workflows as a directed acyclic graph (DAG).
Nodes are sorted into a linear execution order respecting all edge dependencies. If node B depends on output from node A, A always runs first. Circular dependencies throw an error before execution begins.
Before a node runs, all string fields in its data object are processed through Handlebars. This lets node configuration reference outputs from previously executed nodes:
"userPrompt": "Summarise this form response: {{json nodes.abc123.output}}"
Custom Handlebars helpers registered:
{{json value}}— serialises any value to a pretty-printed JSON string{{encodeURI value}}— URI-encodes a string{{#eq a b}}...{{/eq}}— equality block helper
The runWorkflow() function:
- Calls
toposortto get ordered node IDs - Skips
INITIALplaceholder nodes - For each node: resolves templates → calls the executor → stores output in
context.nodes[nodeId] - At each step calls
onNodeStart,onNodeComplete, oronNodeErrorcallbacks - The Inngest function wires these callbacks to both database updates and SSE events
| Node | Executor | Behaviour |
|---|---|---|
| Manual Trigger | manual-trigger.js |
Pass-through. Used for the "Execute workflow" button. |
| Google Form Trigger | google-form-trigger.js |
Receives triggerPayload.formData from the webhook. |
| Stripe Trigger | stripe-trigger.js |
Receives triggerPayload.event from the webhook. |
| Node | Executor | Model / Service | Credential Type |
|---|---|---|---|
| HTTP Request | http-request.js |
Configurable method, URL, body | None |
| OpenAI | openai.js |
gpt-4o-mini (Vercel AI SDK) |
OPENAI |
| Anthropic | anthropic.js |
claude-sonnet-4 (Vercel AI SDK) |
ANTHROPIC |
| Gemini | gemini.js |
gemini-2.5-flash (Vercel AI SDK) |
GEMINI |
| Discord | discord.js |
Webhook message | DISCORD (webhook URL) |
| Slack | slack.js |
Webhook message | SLACK (webhook URL) |
AI node configuration (shared by OpenAI, Anthropic, Gemini):
credentialId— references a saved credential for the API keysystemPrompt(optional) — sets the AI's role/behaviouruserPrompt(required) — the actual prompt, supports{{template}}expressionsvariableName— the key under which this node's output is accessible to downstream nodes as{{nodes.<variableName>.output.text}}
Nodes in the editor show live execution status with no polling:
| Status | Visual |
|---|---|
| Running | Blue animated border + spinner overlay |
| Success | Green border + ✓ checkmark icon (bottom-right) |
| Error | Red border + ✗ icon (bottom-right) |
Two-level SSE architecture:
-
Execution-level stream (
GET /api/executions/:id/stream)- Opened once an execution ID is known
- Events:
node:start,node:complete,node:error,execution:complete,execution:error - Updates
nodeStatusMapAtomin Jotai → each node component re-renders with new status
-
Workflow-level stream (
GET /api/workflows/:id/stream)- Opened automatically when the editor mounts, stays alive the whole session
- Event:
execution:started— fires when any execution begins on this workflow - Auto-triggers the execution-level subscription with the new
executionId
Why two levels? The "Execute workflow" button knows the executionId immediately (it gets it from the API response). But webhook triggers (Google Form, Stripe) run externally — the browser never learns the executionId without the workflow-level channel. The workflow stream bridges this gap.
A singleton class that maintains two Maps:
connections:executionId → Response[]workflowConnections:workflowId → Response[]
The Inngest function holds a direct reference to sseManager and calls send() / sendWorkflow() / close() as execution progresses.
Authentication uses JWT stored in httpOnly cookies, not Authorization headers.
Login flow:
POST /api/auth/login→ verifies password withbcryptjs.compare→ signs JWT withJWT_SECRET→ setstokencookie (httpOnly: true, sameSite: "lax")- All subsequent requests include the cookie automatically (browser-native)
verifyTokenmiddleware extracts and verifies the JWT, attachesreq.user
Why cookies over Bearer tokens:
- httpOnly cookies are not accessible from JavaScript — XSS cannot steal them
- Automatic inclusion by the browser — no token management in frontend code
- Works naturally with SSE (
EventSourcealways sends cookies on same-origin requests)
Frontend:
kyis configured withcredentials: "include"so all API requests send the cookieEventSourceis created with{ withCredentials: true }for the same reasonAuthProvidercallsgetMe()on mount to hydratecurrentUserAtom; on 401 the atom is set tonulland the user is redirected to login
API keys and webhook secrets stored in credentials are encrypted at rest using AES-256-GCM.
Encryption details (src/utils/encryption.js):
- Algorithm:
aes-256-gcm - Key: 32-byte buffer derived from
ENCRYPTION_KEYhex string - IV: 16 random bytes generated per encryption, stored alongside ciphertext
- Auth tag: 16 bytes, stored alongside ciphertext
- Storage:
encrypted_value(hex),iv(hex),auth_tag(hex) columns incredentialtable
Decryption at execution time:
The getCredentialsForWorkflow() service decrypts all credentials needed by a workflow's nodes during the Inngest load-workflow step, passing plain-text values to executors in memory only — decrypted values are never persisted.
- Server-Sent Events (SSE): Next.js API routes run in Vercel's edge/serverless environment which does not support persistent TCP connections. SSE requires a long-lived connection that only a traditional server can provide.
- Inngest integration: The Inngest background function holds a live reference to
sseManager(in-process singleton). This only works in a persistent Node.js process. - Database transactions with RLS: Using
SET LOCALfor row-level security requires a real connection pool that can hold clients across transaction boundaries. - Full control: No edge runtime restrictions, no 10-second function timeouts, no cold starts.
- Execution status (SSE events) is push-based, not request-based. TanStack Query polls or refetches; it is not designed for live SSE streams.
- Jotai atoms are the ideal store for
nodeStatusMap— they are fine-grained (each node subscribes to only its own status), synchronous, and update-friendly. - TanStack Query is still used where appropriate (list views, detail pages).
- Durability: if the server restarts mid-execution, Inngest resumes from the last completed step.
- Retryability: individual steps can be retried without re-running the whole workflow.
- Observability: the Inngest Dev Server UI shows every event, step, and output.
- Decoupling: the HTTP request that starts a workflow returns in
<50ms; execution happens in the background.
Node configuration fields support {{nodes.<nodeId>.output.text}} expressions. This lets users chain outputs between nodes — e.g. feed a Google Form response directly into a Gemini prompt — without any code. Handlebars was chosen for its well-understood syntax, safety (no arbitrary code execution), and ease of extending with custom helpers.
The following were completely removed as part of this migration:
| Removed | Replaced with |
|---|---|
| tRPC (client, server, routers, init, query-client) | Direct REST calls via ky |
| Prisma (schema, client, 12 migration files) | Raw SQL via pg Pool + custom migration runner |
| BetterAuth (auth.ts, auth-client.ts, API route) | Custom JWT + bcryptjs authentication |
| Sentry (instrumentation, configs, example page, API route) | Custom structured JSON logger |
| Polar (subscriptions Polar client) | Subscription hook stubbed |
| Inngest client-side (channels, functions, utils, API route) | Inngest moved entirely to backend |
| Next.js API routes for webhooks | Express webhook module (required for SSE singleton access) |
Server-side prefetch / params-loader files |
Hook-based data fetching via ky API layer |
| mprocs (process manager config) | Separate terminal tabs or concurrently |