I wired a Django REST Framework API to a Next.js 16 front end this week — the most ordinary stack decision there is. Both sides worked. Every request returned 200. The page rendered, the styling was fine, the data looked right.
It was wrong in four separate ways, and only one of them ever produced an error message.
Here is each one, what it looks like from the outside, and the line that fixes it.
1. The page showed 10 tasks. There were 26.
The API had 26 tasks in it. The page listed 10 of them, in a perfectly formatted list, with no warning anywhere.
DRF's pagination wraps your array in an envelope:
{ "count": 26, "next": "...?page=2", "previous": null, "results": [ ... ] }
So you write the obvious thing — data.results — and you have silently accepted the default page size of ten. Nothing throws, because nothing is wrong exactly: you asked for the first page and you got the first page.
The dangerous part is that this failure scales with your success. With twelve rows in development you notice nothing, because ten of twelve looks like a full list. Ship it, let real data arrive, and you are quietly hiding most of your database behind a list that looks complete.
2. In production, the page froze at build time.
This is the one that would have cost me a weekend.
next build printed this:
Route (app)
┌ ○ /
└ ○ /_not-found
○ (Static) prerendered as static content
That little circle means the page called Django during the build, baked the result into HTML, and will serve that HTML forever. I started the production server, added three tasks through the API, and reloaded:
API count now: 29
newest task in API: BRAND NEW TASK 3
page still shows: 10 | Created via correct POST
Cache-busting query string, hard refresh, same thing. The application is not broken. It is working precisely as designed, serving a snapshot of a database from whenever the build ran, and nothing in the output suggests your data has stopped moving.
The fix is one line at the top of the page, and the build output changes from ○ (Static) to ƒ (Dynamic):
export const dynamic = 'force-dynamic';
3. My fix for that ran straight into the next silent failure.
Having made the page dynamic, I also asked for more rows, in the way anyone would:
fetch("http://127.0.0.1:8008/api/tasks/?page_size=100", { cache: "no-store" })
The page still showed ten.
DRF's PageNumberPagination ignores ?page_size= entirely unless you tell it the parameter exists. Here is the whole behaviour:
/api/tasks/ -> 10 rows
/api/tasks/?page_size=100 -> 10 rows
/api/tasks/?page_size=1 -> 10 rows
Three different requests, one answer, no complaint. And it is not just that parameter. On a plain ModelViewSet, every query parameter you have not explicitly wired up is discarded in silence:
/api/tasks/?done=true -> all 26 rows
/api/tasks/?ordering=title -> unchanged order
/api/tasks/?nonsense=banana -> all 26 rows, 200 OK
Your filter checkbox does nothing. Your sort dropdown does nothing. The API cheerfully returns 200 and the full list every time, so the bug reaches you as "the filter feels broken" rather than as an exception.
The fix for the page size is a four-line class:
class SaneP(PageNumberPagination):
page_size_query_param = "page_size"
max_page_size = 500
After which ?page_size=100 returns 30 rows and ?page_size=1 returns one. For filtering and ordering you need django-filter and DRF's OrderingFilter — the point is that until you add them, the parameters are decoration.
4. The server's timezone decided what time every user saw.
Django stored a due date as 2026-10-15T15:44:59Z. The page rendered it with new Date(...).toLocaleString(), inside a server component.
I ran the same build under three server timezones:
| server timezone | what every visitor saw |
|---|---|
| UTC | 10/15/2026, 3:44:59 PM |
| America/New_York | 10/15/2026, 11:44:59 AM |
| Europe/Sofia | 10/15/2026, 6:44:59 PM |
Same instant, same code, same database row. Because the formatting happens on the server, the time your users see is the timezone of the machine that rendered the page — not theirs, and not necessarily the one you developed against. Deploy to a region in a different offset and every timestamp in your product shifts, with nothing in any log to say so.
If the value matters, format it explicitly with a timezone you have chosen, or move the formatting to the client where the browser actually knows where the person reading it lives. What you should not do is what I did, which is let toLocaleString pick up whatever the rendering machine happens to be set to and assume that is a property of the data.
The one that did shout
In fairness, one thing broke loudly and got it exactly right. I posted to the collection URL without a trailing slash, the way you would if you had typed the path from memory:
POST /api/tasks -> 500, RuntimeError
Django's APPEND_SLASH will happily redirect a GET to the slashed version, but it refuses to do the same for a POST, because a redirect would quietly drop the body you were trying to send. Rather than lose your data it raises, says so, and names the setting responsible. Add the slash and you get 201 with the new row in the response.
I would take that trade every time. It cost me thirty seconds and told me precisely what was wrong, which is more than the four silent ones managed between them.
A smaller one that wasted twenty minutes
While measuring the timezone behaviour I kept starting dev servers on different ports, and my results made no sense — two of the three runs rendered nothing at all. Next 16 will not run a second dev server in the same project directory. It prints a note saying another one is already running, gives you its port, and then does nothing further, so my script was politely being redirected to a server I thought I had replaced.
Not a bug, and arguably good behaviour. But it is the same pattern as everything else here: the output was a success message, the exit was clean, and what I believed was happening had not happened for some minutes.
What I take from this
Every one of these has the same shape. A default that is reasonable in isolation — paginate by ten, prerender what looks static, ignore unknown parameters, format dates with the ambient locale — becomes wrong at the seam between two frameworks, and the seam is exactly where nothing is watching.
None of it shows up in a status code. The only reliable way I found any of them was to compare what the API says exists against what the page actually renders, which is worth building as a test on day one: count the rows in the database, count the list items in the HTML, assert they match. That single assertion would have caught three of the four.
And after every production build, look at the little symbols in the route table. A ○ next to a page that shows live data is telling you, quietly, that it does not.
Top comments (0)