Architecture
Barynt is one Next.js application plus PostgreSQL. Redis and S3-compatible storage are optional add-ons. There is no separate backend service: the web pages, the data access, the REST API and the MCP server all live in one codebase and one process.
The stack
| Part | Choice | Notes |
|---|---|---|
| Framework | Next.js 16 (App Router), React 19 | Server Components by default. This Next.js differs from older versions, read node_modules/next/dist/docs/ before writing framework code (see AGENTS.md). |
| Language | TypeScript | Strict. bun x tsc --noEmit runs in CI. |
| Database | PostgreSQL through Prisma 7 | The client is generated into lib/generated/prisma and talks to Postgres through the pg driver adapter. |
| Auth | Auth.js (next-auth v5) | Passkeys, OAuth/OIDC, magic links. JWT sessions. |
| Styling | SCSS Modules | No Tailwind. |
| Rich text | Tiptap / ProseMirror | Documents are stored as JSON. |
| Rate limiting | Redis (ioredis) | Only for the API and MCP keys. |
| Files | S3-compatible, through aws4fetch | Optional. |
| i18n | next-intl | English and German, locale prefix always in the URL. |
| Runtime and tooling | Bun, Biome | Bun installs and runs scripts and tests; the production image runs the Next standalone server. |
Folder layout
app/ Routing only: pages, layouts, route handlers. As little logic as possible. [locale]/(auth)/ Public and sign-in pages: login, invite, join, share, onboarding, OAuth consent [locale]/(default)/ The signed-in app: [workspace]/..., and admin/ (platform administration) api/ Route handlers: /api/v1 (REST), /api/mcp, /api/oauth, /api/auth, health, updatescomponents/ui/ Generic building blocks that know nothing about the domain (atoms/, layout/, charts/)features/ One folder per business domain: issues, projects, workspaces, roles, account, admin, ... <domain>/actions.ts Server Functions ("use server"): everything that changes data <domain>/queries.ts Reads (server only) <domain>/components/ Components of that domain, one folder per componentlib/ Cross-cutting building blocks without a domain: permissions, audit, notify, mail, storage, rbac, richtext, ...i18n/, messages/ Routing and the translation files (de.json, en.json)prisma/ schema.prisma, migrations, seed.ts, bootstrap.tsstyles/ Global SCSS: colors, typography, breakpoints, shared mixinsscripts/ Build and maintenance scripts: test runner, icons, release, docsdeploy/helm/ The Helm chartdocker/ The image's entrypoint and the isolated migration dependenciestests/unit/ All tests, grouped by domainreleases/ Release notes (generated and hand-written), shipped with the appdocs/ guide/ (users) and dev/ (you are here)Dependency direction. app/ uses features/ and components/; features/
use lib/ and components/ui; lib/ uses nothing above it; components/ui
knows no domain and no Prisma. Domain data reaches a generic component as
props (the rich-text editor gets its @ suggestions from
features/issues/components/IssueRichText, not the other way round). When you
are unsure where code belongs, ask which of those it may depend on.
How a page renders
Take the project board, app/[locale]/(default)/[workspace]/project/[projectSlug]/page.tsx:
proxy.ts(Next’s middleware slot) runs first. It sends visitors without a session to the login page and letsnext-intladd the locale prefix./apiis outside its matcher, so route handlers there authenticate themselves.- The layout (
[workspace]/layout.tsx) puts the workspace id from the URL into a request-scoped store (lib/current-workspace.ts), loads the session, and callsnotFound()for a workspace the person may not enter. A layout is a convenience, not the security boundary, see below. - The page is an async Server Component. It awaits
params/searchParams(they are Promises in this Next.js), calls queries infeatures/*/queries.ts(often withPromise.all), and hands plain data to components. - Queries check access themselves and fail empty, never with an exception.
React.cache()deduplicates calls within one request, so the layout and a page can both ask for the same thing. - Components that need interactivity are Client Components (
"use client") that receive the data as props. Layout lives in CSS; behaviour in JS.
Pages are force-dynamic: they depend on the session and the database, nothing is
statically generated against the database.
How a change travels
All changes made by the UI are Server Functions ("use server" in
features/<domain>/actions.ts), not REST calls. A typical one:
requirePermission("issue.update.any", { projectId })(see Permissions). No permission, no work.- Validate input, write to the database, usually in a transaction.
- Side effects:
recordAudit(...)(audit log),notify(...)(inbox and mail),fireWebhookEvent(...)(webhooks),recordProjectChange(...)(so other open tabs notice). revalidatePath(...)so the page re-renders with fresh data.
The REST API and the MCP server do not reimplement this. Both call the same
functions in features/api-v1/ (*ForUser queries and mutations), so the two
surfaces cannot drift on what a request does, only on transport. See
API and MCP internals.
Three ways in, one rule set
| Entry | Authenticates with | Where it checks permissions |
|---|---|---|
| The web app | The Auth.js session cookie | In each query and Server Function. |
/api/v1/* | Authorization: Bearer bry_… API key | The route checks key, rate limit and scope, then the *ForUser function checks the person’s own permissions. |
/api/mcp | An API key or an OAuth access token | Per tool, the same way. |
A scope on a key only ever narrows what its owner may do. There is no second permission system.
Decisions worth knowing
- The workspace lives in the URL, not in a cookie:
/<workspaceId>/.... A workspace’s id is its slug. Nested Server Components read it from the request-scoped store without prop drilling. - Every query and action checks access itself. A layout only protects the pages
beneath it, not every function call. Queries return empty data instead of
throwing, because they render in parallel with the layout and an exception would
become a 500 before
notFound()could act. - A missing thing and a forbidden thing look the same (404). Barynt never tells someone that something exists which they may not see.
- Polling, not streaming, for live updates. Open boards poll
/api/workspaces/[id]/updatesevery few seconds and show a banner when someone else changed something. A plain short GET has nothing for a proxy, CDN or tunnel to buffer. The change record is an in-process map, which is fine for a single container and is the one thing to move to a shared store before running several app replicas, see Backend services. - Optional services degrade quietly. No SMTP: no mail, links to copy. No S3: initials avatars, no attachments. The web app itself never needs Redis; only the API and MCP rate limit does, so those two need a reachable Redis, see Backend services.
- Environment, not settings files. Mail, storage, auth providers and URLs are
configured only through environment variables. Platform-wide switches that an
administrator flips at runtime are in the
SystemSettingstable. - One image, migrations at start. The app container runs
prisma migrate deployand the bootstrap before it serves, so changing the image tag is the whole update. See Build, release and operations.