Musa Yılmaz
·14 min read

Why Django Sessions Are Empty in Server Components

nextjsdjangoauthenticationserver-components

When you run Next.js and Django behind nginx on the same origin, authentication looks like it takes care of itself. The browser posts to /api/auth/login/, Django sets a sessionid cookie, and every request after that carries it automatically. No CORS, no token storage, no localStorage. I covered that setup in running Next.js and Django on a single origin.

Then you decide to kill the loading spinner and move the fetch that loads the current user from a Client Component into a Server Component. The code barely changes. And the page starts calling a logged-in user a guest.

This post explains why that happens and how to do it properly. The examples target Next.js 16 and the App Router — which also brings a rename you need to know about.

Why does it work in the browser but not on the server?

A cookie is browser state. The sessionid cookie lives in the user's browser, and the browser attaches it only to requests it makes itself.

A Server Component does not run in the browser. It runs inside the Node.js process on your server, and a fetch issued from there is, as far as Django is concerned, a completely different client: a new connection with an empty cookie jar. Django sees an unidentified request, and request.user becomes AnonymousUser.

So there is no error and no permission failure. The user simply is not recognised. That is what makes it confusing: the same endpoint returns 200 with a full body in the browser's network tab, and 200 with an empty body when called from the server.

If you are on Django REST Framework and the endpoint is behind IsAuthenticated, you get a clearer signal. The DRF docs put it plainly: with SessionAuthentication, "unauthenticated responses that are denied permission will result in an HTTP 403 Forbidden response." So you see 403 rather than the 401 you would expect from a token setup — because DRF must include a WWW-Authenticate header on a 401, and session authentication has no scheme to send.

The fix is not complicated: read the cookie off the incoming request and put it on the outgoing one. In Next.js you reach the incoming cookies through cookies from next/headers.

The first trap here is a version difference. cookies is now an async function — you cannot use the return value without awaiting it. It was synchronous in Next.js 14 and earlier, and Next.js 15 kept synchronous access working for backwards compatibility, but this is the expected usage now:

// src/lib/api.ts
import { cookies } from 'next/headers'
 
const FORWARDED_COOKIES = ['sessionid', 'csrftoken']
 
export async function apiFetch(path: string, init: RequestInit = {}) {
  const store = await cookies()
 
  const cookieHeader = FORWARDED_COOKIES.flatMap((name) => {
    const cookie = store.get(name)
    return cookie ? [`${cookie.name}=${cookie.value}`] : []
  }).join('; ')
 
  return fetch(`${process.env.API_INTERNAL_URL}${path}`, {
    ...init,
    headers: {
      ...init.headers,
      ...(cookieHeader ? { cookie: cookieHeader } : {}),
    },
  })
}

API_INTERNAL_URL is the service address on the Docker network, something like http://backend:8000. Do not prefix it with NEXT_PUBLIC_: this address should never reach the browser, which could not resolve an internal service name anyway.

Every Server Component now goes through one place:

// app/[locale]/dashboard/page.tsx
import { apiFetch } from '@/lib/api'
 
export default async function DashboardPage() {
  const res = await apiFetch('/api/me/')
  const user = await res.json()
 
  return <h1>Hello {user.username}</h1>
}

The real benefit of writing this once is consistency. A single call where you forget to forward the cookie leaves half the page not recognising the user, and you tend to find out in production.

Which cookies should you forward?

The easy route is calling store.toString() and shipping every incoming cookie to the backend. It works, but it does more than you want.

Browsers accumulate theme preferences, locale choices, analytics identifiers and whatever third-party tools drop. Sending all of it to Django pushes data the backend never asked for into its logs and error reports. Naming the two cookies you actually need, as above, buys you a smaller request header and a narrower data surface.

The two cookies also play different roles. Django's own docs recommend leaving SESSION_COOKIE_HTTPONLY at True "to prevent access to the stored data from JavaScript" — which is the default. csrftoken is deliberately the opposite: it is readable by JavaScript by default (CSRF_COOKIE_HTTPONLY defaults to False), because the client needs to read it and put it in a header.

Do not move login to the server

Moving the login form into a Server Action and posting to Django from there feels like the natural next step. This is where it gets awkward.

The Next.js docs state that cookies cannot be set during Server Component rendering; set and delete only work inside a Server Function or a Route Handler. The reason is HTTP itself: you cannot set a cookie once the response has started streaming.

But that is not the real problem. If you issue the login request from the server, Django returns its Set-Cookie header to your Next.js server, not to the browser. To get that cookie to the user you would have to parse Django's Set-Cookie headers and rebuild them with cookies().set(), carrying Max-Age, Path, SameSite and Secure across by hand. Miss one attribute and the session quietly behaves differently in the browser.

On a single origin you do not need any of this. Issue the login request from the browser:

// src/app/[locale]/login/form.tsx (Client Component)
const res = await fetch('/api/auth/login/', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-csrftoken': csrfToken,
  },
  body: JSON.stringify({ username, password }),
})

Because the request goes to the same origin you do not need to set credentials — the fetch default already sends cookies to the same origin. Django returns Set-Cookie straight to the browser, the cookie is stored with the right attributes, and there is nothing to relay. Refresh the page and your Server Components see the user through apiFetch.

This is where the single-origin architecture pays off most concretely: you never write the most fragile step of authentication at all.

How do you get past CSRF on mutations?

Reads are solved by forwarding the cookie. Writes need a CSRF token as well.

The Django docs are specific about the expectation: on every unsafe request, set "a custom X-CSRFToken header (as specified by the CSRF_HEADER_NAME setting) to the value of the CSRF token." CSRF_HEADER_NAME defaults to HTTP_X_CSRFTOKEN, so the header is X-CSRFToken. The recommended source for the token is the csrftoken cookie.

In a Server Action you need to do both — forward the cookie and put the same value in the header:

// src/app/[locale]/dashboard/actions.ts
'use server'
 
import { cookies } from 'next/headers'
import { revalidatePath } from 'next/cache'
import { apiFetch } from '@/lib/api'
 
export async function updateProfile(formData: FormData) {
  const store = await cookies()
  const csrfToken = store.get('csrftoken')?.value
 
  if (!csrfToken) {
    return { error: 'No session found, please sign in again.' }
  }
 
  const res = await apiFetch('/api/profile/', {
    method: 'PATCH',
    headers: {
      'content-type': 'application/json',
      'x-csrftoken': csrfToken,
    },
    body: JSON.stringify({ name: formData.get('name') }),
  })
 
  if (!res.ok) {
    return { error: 'Update failed.' }
  }
 
  revalidatePath('/dashboard')
  return { ok: true }
}

Taking the token from a cookie and sending it as a cookie at the same time looks odd, but that is exactly how the protection works: an attacking site can cause the victim's browser to send a cookie, yet it cannot read that cookie and copy it into a header. Django wants to see the two match.

Omit the X-CSRFToken header entirely and Django rejects the request with a 403, even though the session is perfectly valid. If you are seeing a 403 while apiFetch is forwarding the cookie, the problem is here rather than in the session.

Watch the Origin header if you call over HTTPS

Django's CSRF check has two branches, and which one you land in depends on how you reach the backend.

Straight from the docs: "CsrfViewMiddleware verifies the Origin header, if provided by the browser, against the current host and the CSRF_TRUSTED_ORIGINS setting." And then: "In addition, for HTTPS requests, if the Origin header isn't provided, CsrfViewMiddleware performs strict referer checking." Referer checking is not done for HTTP requests, because the docs consider that header insufficiently reliable over plain HTTP.

In practice:

  • Calling over the internal network (http://backend:8000) means a plain HTTP request with no Origin header. Referer checking never kicks in and only the token is compared. No extra configuration needed — this is the route I recommend.
  • Calling the public HTTPS address still sends no Origin header, since a server-side fetch does not add one on its own. But the request is HTTPS, so strict referer checking applies, there is no referer either, and the request is rejected.

If you are stuck with the second case, set the Origin header explicitly and trust that origin on the Django side:

# settings.py
CSRF_TRUSTED_ORIGINS = ["https://example.com"]

This setting must include the scheme; a bare hostname will not do. But the better answer is the internal address: one fewer network hop, and you skip this check entirely.

Is protecting routes in proxy.ts enough?

In Next.js 16, middleware.ts was renamed to proxy.ts, and the exported function is now proxy rather than middleware. The docs summarise it as: "Starting with Next.js 16, Middleware is now called Proxy to better reflect its purpose. The functionality remains the same." As of 16 it also defaults to the Node.js runtime, and setting the runtime option there throws an error.

It is the first place you think of for protected routes:

// src/proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
 
const PROTECTED_PATHS = ['/dashboard']
 
export function proxy(request: NextRequest) {
  const path = request.nextUrl.pathname
 
  if (!PROTECTED_PATHS.some((prefix) => path.startsWith(prefix))) {
    return NextResponse.next()
  }
 
  if (!request.cookies.get('sessionid')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
 
  return NextResponse.next()
}
 
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|.*\\..*).*)'],
}

This works, but it is not an authorization layer on its own and you should not treat it as one. The Next.js docs are unusually direct here: Proxy "should not be used as a full session management or authorization solution" and is meant for "optimistic checks".

The reason is documented too. Proxy runs on every route, including prefetched routes. It fires when a user merely hovers a link. Put a database query or a backend call in there and your backend gets hammered by users who never clicked anything. That is why the docs say to read the session from the cookie only and avoid database checks.

Which is exactly what the code above does: it checks that the cookie exists, not that it is valid. An expired, revoked or outright invented sessionid passes this check. The win here is user experience, not security — a signed-out visitor goes straight to the login page instead of seeing an empty dashboard and then being redirected.

One more note: inside Proxy, the cache, next.revalidate and next.tags options on fetch have no effect at all. The docs call this out separately.

Move the real check close to the data

The check that matters belongs as close to the data as possible. The pattern the Next.js docs recommend is to centralise data requests and authorization logic in one layer, and use React's cache so it runs once per render pass:

// src/lib/dal.ts
import { cache } from 'react'
import { redirect } from 'next/navigation'
import { apiFetch } from './api'
 
export const getUser = cache(async () => {
  const res = await apiFetch('/api/me/')
 
  if (res.status === 401 || res.status === 403) {
    return null
  }
 
  if (!res.ok) {
    throw new Error(`Could not load the current user: ${res.status}`)
  }
 
  return res.json()
})
 
export async function requireUser() {
  const user = await getUser()
 
  if (!user) {
    redirect('/login')
  }
 
  return user
}

Without the cache wrapper, five components on the same page calling getUser produce five requests to Django. With it, one per render pass.

Call this from your Server Actions too. The docs say to treat Server Actions "with the same security considerations as public-facing API endpoints" — because that is what they are. They are callable endpoints, and a form not being visible on screen stops nobody.

Do not put the auth check in a layout

A common mistake is putting the check in the layout.tsx of the protected section. Write it once, cover every page below it — or so it looks.

It does not. The Next.js docs explain why: due to Partial Rendering, layouts do not re-render on navigation, which means the user session is not checked on every route change. If a session ends after the user has entered the dashboard, the layout check does not run again and they can keep moving between sub-pages.

The fix is to check close to the data source, or to the component being conditionally rendered. In other words, requireUser belongs in the page or the data layer, not in the layout.

If you only fetch the user in a layout to display it — a user menu in the header, say — that carries a separate cost. A top-level await in a layout delays the first streamed chunk and holds the children behind it. The docs suggest moving that await into a nested Server Component wrapped in Suspense, so the rest of the page streams first.

Keep session data out of the cache

In Next.js 16, fetch requests are not cached by default; caching is now something you opt into with use cache. For user-specific data that is a good default.

Calling cookies() inside a use cache scope is not allowed either — the docs say it fails immediately. The recommended pattern is to read those values outside the cached scope and pass them in as arguments.

And that is where the real trap hides. Pass the session cookie in as an argument and the code runs, but you gain nothing: the cache key is generated from the inputs, and the docs note that "different user and filter combinations will have separate cache entries." You get one entry per user, a hit rate near zero, and a cache that only grows.

The practical rule: use cache is for data that does not depend on the session. Product listings, blog posts and category trees are fine; carts, notification counts and profiles are not.

Summary

Most session problems between Next.js and Django come from missing one fact: the cookie belongs to the browser, and a request you issue from the server does not carry it for you.

The arrangement that works in this setup:

  • Write a single apiFetch that reads cookies with cookies() and attaches them to the outgoing request, and use it everywhere. Forward the cookies you need, not all of them.
  • Issue the login request from the browser. Do not try to rebuild Set-Cookie by hand; a single origin makes that unnecessary.
  • On writes, put the csrftoken value into the X-CSRFToken header. Reach the backend over the internal network and stay out of the referer-checking branch.
  • proxy.ts is an optimistic redirect layer only — it runs on prefetch, so keep backend calls out of it.
  • Do the real check close to the data, wrap it in cache, and do not rely on layouts.
  • Keep session-dependent data out of use cache.

All of this assumes the architecture underneath is already settled. The nginx single-entry-point setup is in running Next.js and Django on a single origin, and the full configuration that brings all three services up together is in a production Next.js + Django setup with Docker Compose.