Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 9 min read

From a GraphQL monorepo to FastAPI and React: the coupling changed

Language Power is a vocabulary trainer I've built three times. The current version is a Python backend, FastAPI with PostgreSQL and Redis, and a React client, kept in two separate repositories and talking over REST. It r

Language Power is a vocabulary trainer I've built three times. The current version is a Python backend, FastAPI with PostgreSQL and Redis, and a React client, kept in two separate repositories and talking over REST. It runs as a private deployment I use every day and a public demo with strict limits. The version before it was a TypeScript monorepo with a GraphQL API and a shared schema.

I built the app for my own language learning, so what follows is about maintaining one developer's project over several years, not about a team or about traffic. Between the two versions I changed more than the repository layout: the language, the API style, the database, authentication and the deployment. So this isn't a controlled comparison of monorepos and separate repositories. It's an account of which dependency caused each cost, and what changed when that dependency went away.

v2: a monorepo while the app was taking shape

Version 2 ran from 2022 to 2025 (language-power-v2). It was a Yarn workspaces + Lerna monorepo with four packages:

Package What it held
server Apollo Server 4 on Express, MongoDB
front React 18 + Apollo Client, built with Webpack 5
ui The design system and the game components, documented in Storybook
shared The GraphQL schema, as .graphql files

I chose GraphQL because I planned to use the app while travelling and wanted each screen to ask for only the fields it needed. The monorepo followed from that: one schema, and every package generating its TypeScript types from it. The server, the front and the design system each have their own codegen config pointing at the same folder (server, front, ui):

schema: '../shared/schema',

During those years I was still working out what the app should be. I added games, rewrote the spaced-repetition scheduler, added Spanish with its verb conjugations and noun genders, and tried features I later dropped, such as tags and a speaking game. The schema changed often.

What the monorepo bought

  • Schema changes surfaced early. Codegen fails when a query in front asks for a field the schema no longer has, and the regenerated types turn stale field names into type errors. This was early feedback, not a guarantee: the production builds skip type checking (tsc --noCheck on the server, happyPackMode in the front's Webpack config), and CI ran lint, codegen and the server's tests, not tsc.
  • One change, one pull request. A new game meant a schema change, a resolver, a game component in ui and a page in front. All of it went into one PR.
  • End-to-end tests in the same repo. Cypress ran against the server and client from the same commit, so there was never a question of which server version a client build was tested against.
  • No package publishing. front imported @lp/ui straight from the workspace, so there was no version to bump between them.

While the schema changed every week, these were the properties I wanted.

What it cost

Upgrades

When I tried to upgrade Apollo on the server and check that it still returned the same data, codegen failed. As I remember it, the packages had ended up needing different codegen versions. I no longer have the error, so I can't say more about the cause than that.

Codegen and its plugins are declared separately in three packages. When upgrading that tooling, I had three configurations and their generated output to check rather than one central setup. Some changes could therefore spread beyond the package I originally wanted to upgrade.

Builds

Neither image can be built from its own package alone. Dockerfile-server copies packages/shared and runs yarn build:server, which runs codegen before compiling. The front image copies shared, front and ui (Dockerfile-front), because @lp/ui has no build step of its own: its main points at src/index.ts (package.json), and front's Webpack compiles the design system's source. A replacement for Webpack would have to handle that source and its build assumptions too.

None of this comes from the monorepo as such. A design system with its own build, or one codegen setup at the root, would have loosened it. I didn't need either when I set the repo up, and the coupling built up gradually.

The same setting, in two places

The incident I remember best was about a number.

Login used a JWT. The server signed it with a lifetime from its TOKEN_TTL variable (token.ts):

const tokenTtl = TOKEN_TTL || DEFAULT_TOKEN_TTL;
const token = jwt.sign({ id, role }, JWT_SECRET, {
  expiresIn: isDemo ? '1h' : `${tokenTtl}d`
});

The client didn't read the expiry from the token. It computed its own from its own TOKEN_TTL and used it to send me to the login page when the session ran out (auth-hook.tsx):

const tokenTTL = Number.parseInt(process.env.TOKEN_TTL || '7');
// ...
const TOKEN_EXPIRATION_PERIOD = isDemo
  ? 3600 * 1000
  : 1000 * 60 * 60 * 24 * tokenTTL;

I deployed v2 by hand to a small DigitalOcean droplet. Building the images on the droplet was too much for it, so I built them locally, pushed them to Docker Hub and only pulled them on the server. That put the two values in different places. The client's was a build argument in my local script (docker-prod.sh), compiled into the bundle:

--build-arg TOKEN_TTL=7 \

The server's was whatever I had typed into its environment on the droplet. I don't remember the exact values, but at one point they didn't match, and the result depended on which side was shorter:

  • Client shorter: I was logged out while my token was still valid.
  • Server shorter: the client still thought I was logged in, so instead of a redirect to the login page, every request failed with an error saying I had to log in.

Changing the client's side meant rebuilding the image, pushing it and redeployingβ€”all for one number.

TOKEN_TTL wasn't the only setting split this way. When I archived v2, I added an ALLOW_SIGNUP flag so that people trying the Docker build could create an account (commit). It had to reach four places: the server's signup check, the client's sign-in and sign-up pages, the server's runtime environment and the client's build arguments. The compose file sets it twice.

The shared repository didn't cause this. Two packages each owned the same value, and nothing made them agree.

v3: separate repositories once the features settled

When I started v3 in 2026, I knew which features I wanted: six games, a scheduler I'd been using for years, and two languages. I was more interested in deploying, upgrading and changing each half on its own schedule. v3 is two repositories:

The backend is a single process with feature modules (auth, words, games, dictionary). The two deployables are the backend container and the client's static build.

The contract is checked at runtime

A Python backend can't share TypeScript types directly. I could have generated them from FastAPI's OpenAPI document. I didn't, because I wanted the two repositories to stay completely separate, with nothing generated from one checked into the other.

Instead, the client describes each response in zod, and the wrapper every API call goes through parses the data before the app uses it (http.ts). Calls whose body isn't used, such as logout, pass schema: null and skip it.

const result = schema.safeParse(data);
if (!result.success) {
  // ...
  throw new ApiError("contract", message, response.status, result.error.issues);
}

A response that doesn't match becomes its own kind of error, "contract", and is reported to Sentry. New fields from the backend are ignored, so adding to a response is safe. A removed or renamed field fails on the first request that hits it.

Upgrading FastAPI doesn't touch the client's dependencies, and neither build needs a file from the other repo.

One owner for each setting

The TOKEN_TTL problem went away for a reason unrelated to the repositories: v3 uses server-side sessions instead of JWTs. The session lifetime exists in one place, session_ttl_seconds: a session lasts 30 days from login. The client doesn't know it. It asks /auth/me who the user is, and a 401 or 403 there means no one is logged in (api.ts). A 401 or 403 from any other endpoint makes the client check /auth/me again. Why I moved to sessions is a separate article.

For values the client does need, the backend sends them. GET /auth/config (router.py) returns the settings a logged-out visitor's screen depends on:

return {
    "signup_enabled": service.signups_open(),
    "signup_open_today": await service.signup_open_today(db),
    "use_mocks": service.mocks_enabled(),
    "account_ttl_hours": settings.account_ttl_hours,
    "searches_per_language": settings.searches_per_language,
}

signup_open_today couldn't be an environment variable at all. The demo allows a fixed number of new accounts per day, so the value comes from the database and changes during the day.

Changing an existing value, such as the demo's account lifetime, needs a backend deploy and no new client build. Adding a field is different: when I added the account lifetime and search limit, the backend change was the router and a test (commit), and the client needed a new release for the two schema fields and the notice that shows them (schemas.ts).

The client's application code reads environment variables only for Sentry (config.ts). The deployment needs one more input, API_ORIGIN, which Netlify uses to proxy /api to the backend.

What v3 gave up

  • Drift isn't checked automatically before deploy. If I rename a field in the API and forget the client, testing the two together locally would show it, but nothing in either CI does: neither runs the other side, and there's no contract test between them. Otherwise it fails on the first request in the deployed app, loudly and specifically.
  • Some rules are copied by hand. The password limits, the limits on a word's fields and the spaced-repetition settings limits exist in both repos. The client's copies say so in comments, such as "Mirrors the server's signup rules (auth/schemas.py)" (password.ts).
  • A cross-cutting feature is two changes in two repos. The demo limits were a set of backend commits and a set of client commits, tested separately, and the backend had to ship first.
  • The config isn't refreshed on its own. The client caches /auth/config with staleTime: Infinity (queries.ts). It's fetched again after a signup is refused with a 429 and cleared on logout, but otherwise an open page keeps the value it loaded with.

Keeping the repositories separate didn't remove the coupling between them. They still share field names, status codes and the /api prefix. Generating the client's schemas from OpenAPI would make the existing API dependency explicit and easier to check, at the cost of a generation step and its tooling. I accepted hand-written schemas as the simpler start, knowing they need keeping in sync.

What I take from it

For this app, the setup that suited each stage was different. While the schema changed every week, a single PR across the stack and shared types were worth the cost of upgrading packages together. Once the features settled, I changed the contract less often and cared more about upgrading and deploying each half on its own, and the runtime check has been enough for one developer and one client.

The most expensive problem in v2, the token lifetime, didn't depend on the repo layout. It came from the same value being owned in two places. A monorepo could have avoided it by having the client read the expiry from the token, and separate repositories could have repeated it. Before blaming the number of repositories, I'd now look for the specific dependency causing the pain.

Next steps

  • Generate the client's zod schemas from OpenAPI in CI, or add a contract test that runs them against the real API, so drift fails before deploy.
  • Give /auth/config a response model. It already appears in the OpenAPI document, but with an untyped response ({}), so generation couldn't reproduce its client schema yet.
  • Serve the validation limits from the backend instead of copying them.

Links

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.