Musa Yılmaz
·7 min read

Django REST + Next.js: Fetching Without Waterfalls

nextjsdjangorest-frameworkserver-components

A Server Component that talks to a Django REST Framework backend usually starts the same way: fetch one resource, then fetch the next thing that depends on it, then the next. That pattern is correct when the second call genuinely needs data from the first. Most of the time it doesn't, and the code still reads top to bottom like it does — which means every request to Django waits for the one before it to finish, even though nothing forced that order.

This is the same DRF backend I used for session cookies in Server Components and the single-origin setup. This piece is about the shape of the fetch calls themselves once auth and routing are sorted out.

The waterfall hiding in plain sight

Take a project dashboard: the project's own fields, and the list of tasks under it.

async function getProject(id: string) {
  const res = await apiFetch(`/api/projects/${id}/`);
  return res.json();
}
 
async function getTasks(id: string) {
  const res = await apiFetch(`/api/projects/${id}/tasks/`);
  return res.json();
}
 
export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const project = await getProject(id); // waits here
  const tasks = await getTasks(id); // then starts
  return <ProjectView project={project} tasks={tasks} />;
}

getTasks doesn't need anything getProject returns — the task list only needs the URL's id. But because the second await sits after the first one in the same function, the runtime doesn't start that request until the first has fully resolved: Django receiving it, running the queryset, serializing the response, and the bytes coming back over the wire. The Next.js docs call this out directly in their sequential vs. parallel fetching examples: "within any component, multiple async/await requests can still be sequential if placed after the other" — even though layouts and pages themselves render in parallel by default.

Fixing it with Promise.all — and its failure mode

Start both requests before awaiting either:

export default async function ProjectPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
 
  // both requests fire immediately, neither waits on the other
  const projectPromise = getProject(id);
  const tasksPromise = getTasks(id);
 
  const [project, tasks] = await Promise.all([projectPromise, tasksPromise]);
  return <ProjectView project={project} tasks={tasks} />;
}

Calling getProject(id) without await starts the fetch and returns a promise immediately; the function keeps running past it. Both requests are in flight against Django before either has come back. The total wait is bounded by the slower of the two, not the sum.

The catch: Promise.all rejects as a whole the moment any one of its promises rejects. If the tasks endpoint 404s because the project has none configured yet, getProject — which resolved fine — is thrown away along with it, and the whole page falls through to error.tsx. That's the right behavior when every piece is required to render anything meaningful. It's the wrong behavior when one piece is optional.

Promise.allSettled for the parts that are allowed to fail

Say the dashboard also shows a "team" panel, but plenty of projects don't have a team assigned yet, and DRF's NotFound sends back a 404 for that case. You don't want a missing team to take down the project and task data that loaded fine:

const [projectResult, tasksResult, teamResult] = await Promise.allSettled([
  getProject(id),
  getTasks(id),
  getTeam(id),
]);
 
if (projectResult.status === "rejected") {
  notFound();
}
 
const project = projectResult.value;
const tasks = tasksResult.status === "fulfilled" ? tasksResult.value : [];
const team = teamResult.status === "fulfilled" ? teamResult.value : null;

allSettled never rejects — every entry resolves to a fulfilled result carrying a value, or a rejected result carrying a reason, so a failed team lookup degrades to an empty panel instead of an error page. Reserve this for genuinely optional data; using it everywhere just means you've written your own ad hoc error handling for cases Promise.all would have caught for free.

The DRF default that breaks the shape of your data

Here's the one that gets people who copy a .map() over data.results from a tutorial: DRF's DEFAULT_PAGINATION_CLASS and PAGE_SIZE settings are both None out of the box. Pagination is opt-in. A viewset with no pagination_class set and no global default returns a plain JSON array from GET /api/tasks/ — not the count/next/previous/results envelope. The moment someone on the backend turns on PageNumberPagination (often much later, once a list gets big enough to matter), every Server Component that did const tasks = await getTasks(id) and then tasks.map(...) breaks, because tasks is now an object with a results key, not an array.

Type the response the way your backend is actually configured, not the way a paginated DRF tutorial assumes it is:

type PaginatedResponse<T> = {
  count: number;
  next: string | null;
  previous: string | null;
  results: T[];
};
 
async function getTasks(id: string, page = 1) {
  const res = await apiFetch(`/api/projects/${id}/tasks/?page=${page}`);
  const data: PaginatedResponse<Task> = await res.json();
  return data;
}

The query parameter name (page by default, via page_query_param) and the response envelope come from PageNumberPagination; LimitOffsetPagination uses limit/offset instead and the same count/next/previous/results shape. Thread whichever one your viewset uses through searchParams on the page component rather than hardcoding ?page=1. If pages start climbing into the thousands, offset-based pagination gets slow for the same reason it does everywhere — I cover the cursor alternative in why OFFSET pagination gets slow.

Treat DRF's 404 as data, not an exception

When getProject hits a project that doesn't exist, DRF's NotFound exception produces HTTP 404 with a body carrying a single detail key set to "Not found.". Checking res.ok and calling Next's notFound() gives you Next's own not-found page. Throwing a generic Error instead routes to error.tsx, which is meant for the unexpected case, not "the ID in the URL doesn't exist":

async function getProject(id: string) {
  const res = await apiFetch(`/api/projects/${id}/`);
  if (res.status === 404) {
    notFound();
  }
  if (!res.ok) {
    throw new Error(`Failed to load project ${id}`);
  }
  return res.json();
}

The same split applies to validation errors. DRF returns those with field names as keys — a title key holding an array of messages like "This field may not be blank." — which is meaningful to a form handler and meaningless to error.tsx. Expected, structured failures belong in return values or notFound(); only genuinely unexpected ones should reach a throw.

error.tsx got a second recovery function in 16.2

If your DRF backend is briefly unreachable and a fetch throws, error.tsx catches it. Up through Next 16.1 the component only received reset(), which clears the error boundary's local state and re-renders its children — without re-running the data fetch that failed. If the backend was still down, you'd see the same error again instantly, or a confusing blank flash if it wasn't.

Next 16.2 adds unstable_retry, which does what people generally expected reset to do: it re-fetches and re-renders the segment, not just clears the boundary.

"use client";
 
export default function Error({
  error,
  unstable_retry,
}: {
  error: Error & { digest?: string };
  unstable_retry: () => void;
}) {
  return (
    <div>
      <h2>Couldn't load this project.</h2>
      <button onClick={() => unstable_retry()}>Try again</button>
    </div>
  );
}

reset is still there for the narrower case where you deliberately don't want to redo the fetch. One more thing worth knowing before you reach for either: error.message for exceptions thrown in Server Components is replaced with a generic message in production, with the real detail only available via error.digest against your server logs. It's a deliberate choice to avoid leaking backend error text to the client — which is one more reason to catch DRF's structured 4xx responses explicitly instead of letting them surface as thrown errors.

The pattern, in short

Independent requests to Django should start together, not one after another — Promise.all when every piece is required, Promise.allSettled when some are genuinely optional. Don't assume a list endpoint is paginated or isn't; DRF ships with pagination off, and the response shape changes the day someone turns it on. Treat DRF's 4xx responses as data your component branches on — notFound(), a return value — and save throw plus error.tsx for failures nobody planned for, where unstable_retry is now the function that actually tries again.