Rewriting a Next.js backend in Python without users noticing
In short: I moved an app's API out of Next.js routes and into a FastAPI service one endpoint at a time, keeping every request and response shape the same, and only pointed the frontend at the new…
- published
- read time
- 5 min
- words
- 1,023
- lang
- en
- filed under
- Engineering
In short: I moved an app's API out of Next.js routes and into a FastAPI service one endpoint at a time, keeping every request and response shape the same, and only pointed the frontend at the new service once a script said both sides matched. What broke was everything the contract didn't describe: casing, state, auth and one endpoint that only looked like streaming.
The app started the way a lot of AI apps start: a Next.js frontend with its API routes living right next to the pages. Chat, speech, saved items and user settings, all in TypeScript, all deployed together. That's a fine way to begin. It stopped being fine once most of the work in those routes was model calls and audio.
Why move at all
- The AI work lives in Python. Models, audio tooling and the libraries I reach for first are all Python. Every feature meant translating an idea from a notebook into TypeScript.
- Long connections don't belong in page routes. A WebSocket that streams speech wants a process that stays up, not a function that spins up per request.
- One deploy for two very different things. A copy change on a page and a change to the speech pipeline went out together. I wanted them apart.
The new service is FastAPI in a single container, deployed to Cloud Run. The hard requirement was that nobody using the app should be able to tell the day it happened.
The method: parity, one endpoint at a time
- List every route. Method, path, query parameters, request body, response body, status codes.
- Mark what the UI actually calls. Some routes were for features the main screens never touch. Those can wait.
- Port one endpoint, with the same shapes. Same field names, same types, same errors. Better design can come later.
- Diff old against new with a script, not by clicking around.
- Switch with one setting. The frontend reads its API base URL from an environment variable. Flipping it is the whole cutover, and flipping it back is the whole rollback.
The tracking sheet looked something like this. The routes below are made up for illustration, not the real ones, but the columns are the ones I used.
| Feature | Method | Next.js route | Python route | Status |
|---|---|---|---|---|
| Send a message | POST | /api/messages | /v1/messages | Moved |
| Speech streaming | WS | /api/stream | /v1/stream | Moved |
| Saved items | GET, POST | /api/items | /v1/items | Moved |
| Settings | GET, POST | /api/settings | /v1/settings | Moved |
| Optional features | Various | /api/... | None yet | Stayed in Next.js |
Everything the main screens use moved. A handful of routes for optional features stayed where they were, and the frontend keeps calling Next.js for those. Nothing forces you to move everything on the same day.
The parity script
You can't compare LLM endpoints by value. Ask the same question twice and you get two different answers. What you can compare is the shape: same status code, same keys, same types. That catches nearly every break that matters to a frontend.
import httpx
OLD = "http://localhost:3000/api"
NEW = "http://localhost:8000/v1"
CASES = [
("GET", "/items", {"userId": "test-user"}, None),
("GET", "/settings", {"userId": "test-user"}, None),
("POST", "/messages", None, {"text": "Hello"}),
]
def shape(x):
if isinstance(x, dict):
return {k: shape(v) for k, v in sorted(x.items())}
if isinstance(x, list):
return [shape(x[0])] if x else []
return type(x).__name__
def check(method, path, params, body):
with httpx.Client(timeout=60) as c:
a = c.request(method, OLD + path, params=params, json=body)
b = c.request(method, NEW + path, params=params, json=body)
ok = a.status_code == b.status_code and shape(a.json()) == shape(b.json())
print("OK " if ok else "DIFF", method, path, a.status_code, b.status_code)
if not ok:
print(" old:", shape(a.json()))
print(" new:", shape(b.json()))
return ok
if __name__ == "__main__":
results = [check(*case) for case in CASES]
raise SystemExit(0 if all(results) else 1)
For the speech endpoint, compare the content type and check that the body is non-empty audio instead of parsing JSON. Run the script against both servers locally first, then against the deployed service before you flip the switch.
What broke, or nearly did
Casing
Python code wants snake_case names. The frontend reads camelCase. The fix is to stop fighting it: the wire format stays camelCase, exactly as the frontend expects, and snake_case lives only inside Python. Pydantic can do this for you with an alias generator, so your code stays idiomatic and the JSON stays identical.
Numbers and nulls
JavaScript has one number type. Python has two. A field that is 1 on one side and 1.0 on the other shows up in the shape diff as int against float. Missing fields and null fields are the same kind of trap. The script finds these. Your eyes won't.
Streaming that wasn't
The speech socket sent audio in chunks, and that looked like streaming. It generated the full file first. Parity said the messages matched, and they did. Parity can't tell you that both versions were slow in the same way. I wrote about that in where the latency actually goes.
State
To ship the port quickly, the first Python version kept saved items and history in memory. That is fine on a laptop. On Cloud Run, a restart wipes it, and two instances each hold half the data. It's a known gap with a database on the list, not a surprise. But it's the kind of thing a parity script can't see, because each request on its own looks perfect.
userId in the query string, make sure the new service checks it against a verified token before it returns anyone's history. Same shapes is not the same as same security.Before you start your own port
Write the route list today, before any Python. For each route, note the method, the parameters, one real request and one real response, and whether the main UI calls it. Turn the requests into CASES for a script like the one above. Then decide two more things up front: where the state will live, and how the new service will know who is calling. Those are the two things that broke for me that no diff will catch.
related