A new internal tool is a Next.js frontend and a FastAPI (or Node/TypeScript) backend that
live together in one repo. The frontend is reached through the portal at /apps/<slug>;
it calls the backend server-to-server and forwards the portal's JWT. This guide gives you the
repo skeleton, explains why everything sits in one tree, and points you down the path from
git init to a tile on the portal home page.
This guide is about structure — where the files go and why. For the pieces it links to rather than repeats, see Adding portal auth to your app (the JWT contract), Registering an app (the admin side), and Just recipes (the build commands).
One repo per app, both services inside it
The decision in one line:
One repo per app (the org stays polyrepo), with the frontend and backend co-located inside it (each app repo is a small monorepo). The repo is polyglot; every container is monoglot.
Two things often get conflated under "monorepo." Keep them apart:
- Org level → polyrepo. Each app is its own repo. It already deploys as its own image, with its own slug, version, and quadlet — so a per-app repo matches how it ships. Don't build one giant repo holding every app; that fights the deploy model and widens the blast radius of any change.
- Project level → monorepo. Inside one app's repo, the frontend and backend sit side by side.
If you've avoided multi-language monorepos before, that instinct is right for the reason it's usually right — and it doesn't apply here. Read on.
Why co-locate the two services
Most of the work in this ecosystem is done by an LLM coding assistant working end-to-end, not by separate frontend and backend teams. That changes the trade-off:
-
Context locality is the whole game for an agent. The frontend↔backend boundary is a contract — a Pydantic model on one side, a
fetchcall and a type on the other. When both sides live in one tree, the agent sees them in one context window and changes them in one diff, one PR. Split into two repos and the same change becomes two checkouts, two PRs, and a deploy-ordering dance — exactly the coordination an agent handles worst. This is why the usual "big teams → separate repos" logic inverts here. -
The polyglot objection is cancelled by containers. The reason to avoid Python next to Node is usually toolchain collision on a dev machine. But each service builds in its own container off its own
Dockerfile, so the repo is polyglot while every build stays monoglot. Nothing pollutes anything. -
End-to-end is one command. One
docker-compose.dev.ymlstands the whole app up; onejust preflightgates both languages before an image ships. No cross-repo CI choreography.
sample-auth-app in the portal repo is this shape today — a Next.js frontend
plus a FastAPI backend/, each with its own dev Dockerfile, both verifying the
same portal JWT. It's the worked example behind everything below.
Pick your stack
The shape above is constant; the languages inside it are a short, opinionated menu. Stay on it unless you have a concrete reason not to — a portal full of apps that all look and build the same way is the point.
| Layer | Standard | Use when |
|---|---|---|
| Frontend | Next.js + TypeScript + Tailwind | Always. There's one frontend stack. |
| Backend | FastAPI (Python) — preferred | The default for new apps. Reach for it first. |
| Backend (alt) | Node.js + TypeScript | The app is JS-heavy, shares logic/types with the frontend, or the team is TS-only. |
| Backend (rare) | Rust / Go | Only when a specific performance or concurrency need genuinely warrants it — realistically uncommon for an internal portal app. |
A few things this table is quietly saying:
- The frontend isn't a choice. Every app is Next.js + TypeScript + Tailwind so they share a design language (see Match the portal's look) and so an agent moving between apps never re-learns the frontend.
- FastAPI is the house backend. It's the preferred default;
shyft-authhas first-class Python support and the sample app uses it. (Flask is legacy — new apps don't start there.) - Node/TypeScript is a first-class alternative, not a fallback. A single-language
(all-TS) repo can be the right call when the backend is thin or type-shares heavily
with the frontend.
shyft-authworks server-side in plain Node — see the Node backend variant below. - Rust/Go are escape hatches. Allowed when warranted, but if you're reaching for
one for a portal app, sanity-check that the need is real. The repo shape and the
auth contract are identical regardless — only the
backend/toolchain and itsDockerfilechange.
The standard layout
my-app/ # repo name == portal slug
├── frontend/ # Next.js
│ ├── app/ # App Router
│ │ ├── health/route.ts # GET /health — portal tile poll
│ │ └── .well-known/app-roles/route.ts
│ ├── next.config.ts # basePath: "/apps/my-app" (routing Model B)
│ ├── package.json
│ └── Dockerfile
├── backend/ # FastAPI
│ ├── app/
│ │ ├── main.py # /health, /.well-known/app-roles, root_path
│ │ ├── routers/
│ │ └── models/
│ ├── pyproject.toml # uv
│ └── Dockerfile
├── justfile # standard recipe names, fanned across both
├── docker-compose.dev.yml # frontend + backend + postgres, one network
├── .devcontainer/
├── AGENTS.md # agent conventions for this repo (required)
└── README.md
frontend/ and backend/ are symmetric siblings — neither lives at the repo
root. That removes any "where does the frontend start?" ambiguity for a human or
an agent, and it makes the root justfile and compose file the obvious
orchestration layer over two equal services.
sample-auth-app puts the Next.js app at the repo root with backend/ beside it.
New apps should use the symmetric frontend/ + backend/ layout above — it's the
one place the standard intentionally evolves past the sample.
Backend variant: a Node/TypeScript service
If you pick the TypeScript backend, only the inside of backend/ changes — the
repo shape, the BFF relationship, and the auth contract are identical. The backend
becomes a Node service (Fastify/Express/plain http — your call) that verifies the
portal JWT with the same @shyft/auth library the frontend uses:
backend/ # Node + TypeScript (replaces the FastAPI tree)
├── src/
│ ├── server.ts # /health, /.well-known/app-roles
│ └── routes/
├── package.json
├── tsconfig.json
└── Dockerfile # node:* base instead of python:*
@shyft/auth exposes a framework-agnostic verifyPortalJwt(req, { jwksUrl, expectedIss, expectedAud }) that works in any Node handler — the same function the
Next.js frontend calls. So an all-TypeScript app verifies the portal token the same
way on both sides. The verification code (Python and Node) lives in
Adding portal auth to your app.
Same tree, same /health + /.well-known/app-roles + JWT-verify contract — just a
backend/ in that language with its own Dockerfile. There's no shyft-auth SDK
for them yet, so you verify the RS256 token against the portal's JWKS by hand. Worth
it only when the need is real.
How the two services relate
The portal proxies only the frontend. The frontend is a BFF: its server layer (route handlers / server components) calls FastAPI over the private container network. The backend is never registered with the portal and never gets a host port.
browser ──▶ portal ──▶ frontend (Next.js, :3100) [registered at /apps/my-app]
│
└─▶ backend (FastAPI, :3101) [private, server-to-server]
Auth rides straight through. The portal mints a short-lived JWT
(aud=my-app, RS256, ~60s) and hands it to the frontend. The frontend
forwards that same token on its calls to the backend, and both services verify
it with shyft-auth. One audience, one token, verified twice — see
Adding portal auth to your app for the verification code on
each side.
If an app genuinely needs a directly-proxied API (e.g. a CLI or webhook hits the backend without going through the frontend), register it as a second app with its own slug — but that's the exception. The default is a private backend behind the frontend BFF.
Routing: the frontend uses Model B
Because Next.js bakes its base path in at build time, the frontend sets
basePath: "/apps/my-app" (Model B — static prefix). The backend, being private
and prefix-unaware, just serves at root. If you ever do expose the backend
directly, give it Model A (derive root_path from X-Forwarded-Prefix). The full
rules and the trailing-slash gotcha live in the routing section of
Adding portal auth to your app.
The root justfile orchestrates both services
Keep the standard recipe names — the bodies just fan out across the two services:
set shell := ["bash", "-cu"]
default:
@just --list
# install both toolchains
install:
cd frontend && npm ci
cd backend && uv sync --frozen
# run both services for local dev (compose owns the wiring)
dev:
docker compose -f docker-compose.dev.yml up
# gate BOTH languages before an image is built
preflight: lint typecheck test
lint:
cd frontend && npm run lint
cd backend && uv run ruff check .
typecheck:
cd frontend && npx tsc --noEmit
cd backend && uv run pyright
test:
cd frontend && npx playwright test
cd backend && uv run pytestjust install sets up both; just preflight fails if either side is unhappy.
Image build and publish follow the per-service patterns in
Just recipes — one image and one quadlet per service.
Where the recipes live: one justfile at the repo root is the canonical
entry point — it's what humans and CI both call, and it orchestrates the two
services. A service may keep its own language-native scripts (npm scripts, a
Makefile), but the root just recipe names are the contract, identical to
every other Shyft repo. The full naming standard and per-type bodies are in
Just recipes; this guide only adds the "fan across two
services" wiring.
Match the portal's look
Every frontend is Next.js + Tailwind for a reason: apps should feel like one product, not a dozen. The portal publishes its design tokens so you don't eyeball colors and spacing.
-
Browse the system at
/clarity— the live design-system page: color scales, semantic tokens, type, shadows, and the Clarity components. -
Pull the tokens from the portal's machine-readable endpoint and build on them instead of redefining your own:
GET /api/v1/dev-guide/tokens.css → text/css (the :root { … } token block)@importit (or inline it at build) and map the CSS variables into your Tailwind theme via@theme inline, exactly as the portal's ownglobals.cssdoes. Your--bg-elev,--accent,--font-display, etc. then track the portal's. The endpoint is public and ETag-cached, so a build step can re-fetch it cheaply.
Today the shared surface is the token set (tokens.css) plus the /clarity
reference. There's no published Clarity component package to install, so match the
tokens and mirror the component patterns from /clarity by hand. A shippable
component library is future work.
Point your coding agent at the docs
Most of this work is done by an LLM agent, so these guides are built to be read by
machines, not just people. The same content behind /docs is served as a small,
public, no-auth HTTP API — point your assistant at it and it can pull conventions on
demand instead of guessing.
The one URL to remember is the self-describing entry point:
GET /api/v1/dev-guide/capabilities → JSON: every endpoint, described
From there the agent can reach the rest:
| Endpoint | Returns |
|---|---|
GET /api/v1/dev-guide/capabilities | This index — start here. |
GET /api/v1/dev-guide/docs | Every public guide (slug, title, tags, paths). |
GET /api/v1/dev-guide/docs/{slug} | The raw MDX of one guide (text/markdown). |
GET /api/v1/dev-guide/search?q=… | Substring scan across all guides. |
GET /api/v1/dev-guide/tokens.css | The design tokens (see above). |
A good first move in a new app's AGENTS.md is to tell the assistant exactly this:
"the portal's conventions are live at /api/v1/dev-guide/capabilities — fetch it,
then read the guides you need." That way the agent works from the current docs, not
its training-time guess at how this ecosystem works.
AGENTS.md is required, not optional
Since the work is mostly agent-driven, every app repo ships an AGENTS.md at the
root so an assistant picks up the conventions before writing a line. At minimum it
should state:
- This is the symmetric
frontend/+backend/layout; the frontend is the BFF. - The routing model (
basePathModel B for the frontend) and the slug. - The auth flow: verify the portal JWT with
shyft-auth; the frontend forwards it to the backend. - The two required endpoints on each service:
/healthand/.well-known/app-roles. - Where the live docs are: "fetch
/api/v1/dev-guide/capabilitiesand read the relevant guides before writing code" (see Point your coding agent at the docs). - That the frontend pulls design tokens from
/api/v1/dev-guide/tokens.cssrather than inventing its own. - Any framework caveat the portal itself carries (e.g. "read the Next.js docs in
node_modules/next/dist/docs/before writing code").
From empty repo to a tile on the portal
- Create the repo with the layout above (repo name = the slug you'll
register). Copy the dev
Dockerfiles and compose file fromsample-auth-appas starting points. - Wire auth on both services — follow Adding portal auth to your app.
- Run it locally with
just dev; confirm both/healthendpoints answer and the frontend can reach the backend. - Register it with the portal — Registering an app walks the admin UI and the Base-URL choice. The Base URL points at the frontend.
- Ship it — CI builds the image and pushes to ECR; the deploy host pulls and restarts via its quadlet. Same shapes as the portal's own pipeline.
The frontend↔backend contract. For v1 we deliberately don't prescribe how the
two stay in sync — start with plain typed fetch calls. When an app outgrows that,
the two paths to weigh are generating a TypeScript client from FastAPI's OpenAPI
(one machine-checkable source of truth) or a hand-maintained shared types file.
We'll standardise one once a real second app shows us which it wants.
A template repo / just new-app generator. Until a degit-able starter
lands, copy sample-auth-app and rename.