├── .editorconfig
├── .github
├── assets
│ └── how-it-works-production.svg
└── workflows
│ └── test.yml
├── .gitignore
├── .pre-commit-config.yaml
├── CHANGELOG.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── django_nextjs
├── __init__.py
├── app_settings.py
├── apps.py
├── asgi.py
├── exceptions.py
├── proxy.py
├── render.py
├── templates
│ └── django_nextjs
│ │ └── document_base.html
├── urls.py
├── utils.py
└── views.py
├── pyproject.toml
├── setup.py
└── tests
├── __init__.py
├── settings.py
├── templates
└── custom_document.html
└── test_render.py
/.editorconfig:
--------------------------------------------------------------------------------
1 | # https://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | indent_style = space
7 | indent_size = 4
8 | end_of_line = lf
9 | max_line_length = 120
10 | insert_final_newline = true
11 | trim_trailing_whitespace = true
12 | charset = utf-8
13 |
14 | # Do not add "md" here! It breaks Markdown re-formatting in PyCharm.
15 | [*.{js,ts,jsx,tsx,json,yml,yaml,md}]
16 | indent_size = 2
17 |
18 | [*.md]
19 | # Shorter lines in documentation files improves readability
20 | max_line_length = 80
21 | # 2 spaces at the end of a line forces a line break in MarkDown
22 | trim_trailing_whitespace = false
23 |
24 | [Makefile]
25 | indent_style = tab
26 |
--------------------------------------------------------------------------------
/.github/assets/how-it-works-production.svg:
--------------------------------------------------------------------------------
1 |
118 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: tests
2 |
3 | on: [push, pull_request, workflow_dispatch]
4 |
5 | jobs:
6 | pre-commit:
7 | name: Run pre-commits
8 | runs-on: ubuntu-latest
9 | steps:
10 | - uses: actions/checkout@v4
11 | - uses: actions/setup-python@v4
12 | - uses: pre-commit/action@v3.0.0
13 |
14 | test:
15 | name: Test
16 | runs-on: ubuntu-latest
17 | strategy:
18 | matrix:
19 | # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django
20 | python-version: ["3.10", "3.11", "3.12", "3.13"]
21 | django-version: ["4.2", "5.0", "5.1", "5.2"]
22 | exclude:
23 | - python-version: "3.13"
24 | django-version: "4.2"
25 | - python-version: "3.13"
26 | django-version: "5.0"
27 | include:
28 | - python-version: "3.9"
29 | django-version: "4.2"
30 |
31 | steps:
32 | - uses: actions/checkout@v4
33 | - name: Set up Python ${{ matrix.python-version }}
34 | uses: actions/setup-python@v5
35 | with:
36 | python-version: ${{ matrix.python-version }}
37 | - name: Install dependencies
38 | run: |
39 | python -m pip install --upgrade pip
40 | pip install django~=${{ matrix.django-version }}
41 | pip install -e ".[dev]"
42 | - name: Run tests
43 | run: |
44 | pytest --cov
45 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.egg-info
2 | *.pyc
3 | .*cache
4 | .coverage
5 | .python-version
6 | .idea/
7 | .tox/
8 | build/
9 | dist/
10 | htmlcov
11 | venv/
12 |
13 | .vscode/settings.json
14 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # See https://pre-commit.com for more information
2 | # See https://pre-commit.com/hooks.html for more hooks
3 | exclude: ".excalidraw$"
4 |
5 | repos:
6 | - repo: https://github.com/pre-commit/pre-commit-hooks
7 | rev: "v5.0.0"
8 | hooks:
9 | - id: trailing-whitespace # trims trailing whitespace
10 | args: [--markdown-linebreak-ext=md]
11 | - id: end-of-file-fixer # ensures that a file is either empty, or ends with one newline
12 | - id: check-yaml # checks syntax of yaml files
13 | - id: check-json # checks syntax of json files
14 | - id: check-added-large-files # prevent giant files from being committed
15 | - id: fix-encoding-pragma # removes "# -*- coding: utf-8 -*-" from python files (since we only support python 3)
16 | args: [--remove]
17 | - id: check-merge-conflict # check for files that contain merge conflict strings
18 |
19 | - repo: https://github.com/adamchainz/django-upgrade
20 | rev: "1.25.0"
21 | hooks:
22 | - id: django-upgrade
23 | args: [--target-version, "4.2"]
24 |
25 | - repo: https://github.com/asottile/pyupgrade
26 | rev: "v3.19.1"
27 | hooks:
28 | - id: pyupgrade
29 | args: [--py39-plus]
30 |
31 | - repo: https://github.com/pycqa/isort
32 | rev: "6.0.1"
33 | hooks:
34 | - id: isort
35 | name: isort (python)
36 |
37 | - repo: https://github.com/psf/black
38 | rev: "25.1.0"
39 | hooks:
40 | - id: black
41 |
42 | - repo: https://github.com/ikamensh/flynt
43 | rev: "1.0.1"
44 | hooks:
45 | - id: flynt
46 | args: [--aggressive, --line-length, "120"]
47 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | [View all releases on GitHub](https://github.com/QueraTeam/django-nextjs/releases)
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 QueraTeam
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.md
2 | recursive-include django_nextjs/templates *
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Django-Next.js
2 |
3 | [](https://github.com/QueraTeam/django-nextjs/actions)
4 | [](https://pypi.org/project/django-nextjs/)
5 | 
6 | [](https://github.com/QueraTeam/django-nextjs/blob/master/LICENSE)
7 | [](https://github.com/psf/black)
8 |
9 | **django-nextjs** allows Django and Next.js pages to work together seamlessly.
10 | When a user opens a Next.js page, Django receives the initial request, queries the Next.js server for the HTML response, and returns it to the user.
11 | After opening a Next.js page, the user can navigate to other Next.js pages without any additional requests to Django, since Next.js internal paths (`/_next/...`) are handled directly by Next.js.
12 |
13 | 
14 |
15 | To simplify the setup and eliminate the need for a reverse proxy like Nginx during development, django-nextjs also acts as the reverse proxy for Next.js internal paths when Django's `DEBUG` setting is `True`.
16 |
17 | ## Table of contents
18 |
19 | - [Compatibility](#compatibility)
20 | - [Why django-nextjs?](#why-django-nextjs)
21 | - [Getting started](#getting-started)
22 | - [Setup Next.js URLs in production](#setup-nextjs-urls-in-production)
23 | - [Usage](#usage)
24 | - [The `stream` parameter](#the-stream-parameter)
25 | - [Customizing the HTML response](#customizing-the-html-response)
26 | - [Notes](#notes)
27 | - [Settings](#settings)
28 | - [`nextjs_server_url`](#nextjs_server_url)
29 | - [`ensure_csrf_token`](#ensure_csrf_token)
30 | - [`public_subdirectory`](#public_subdirectory)
31 | - [Contributing](#contributing)
32 | - [License](#license)
33 |
34 | ## Compatibility
35 |
36 | - **Python**: 3.9, 3.10, 3.11, 3.12, 3.13
37 | - **Django**: 4.2, 5.0, 5.1, 5.2
38 |
39 | ## Why django-nextjs?
40 |
41 | django-nextjs is designed for projects
42 | that need both Django pages (usually rendered by Django templates) and Next.js pages. Some scenarios:
43 |
44 | - You want to add some Next.js pages to an existing Django project.
45 | - You want to migrate your frontend to Next.js, but since the project is large, you want to do it gradually.
46 |
47 | If this sounds like you, **this package is the perfect fit**.
48 | This package improves the development experience
49 | by allowing everything to be accessible from a single port
50 | without needing a reverse proxy,
51 | while ensuring all Next.js features like fast refresh continue to work.
52 | It also helps in production by eliminating the need to maintain a list of Next.js page paths in the reverse proxy configuration.
53 |
54 | However, if you’re starting a new project and intend to use Django purely as an API backend with Next.js as a standalone frontend, you don’t need this package.
55 | Simply run both servers and configure your public web server to route requests to Next.js; this provides a more straightforward setup.
56 |
57 | ## Getting started
58 |
59 | Install the latest version from PyPI:
60 |
61 | ```shell
62 | pip install django-nextjs
63 | ```
64 |
65 | Add `django_nextjs` to `INSTALLED_APPS` in your Django settings:
66 |
67 | ```python
68 | INSTALLED_APPS = [
69 | ...
70 | "django_nextjs",
71 | ]
72 | ```
73 |
74 | Configure your project's `asgi.py` with `NextJsMiddleware` as shown below:
75 |
76 | ```python
77 | import os
78 |
79 | from django.core.asgi import get_asgi_application
80 |
81 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
82 | django_asgi_app = get_asgi_application()
83 |
84 | from django_nextjs.asgi import NextJsMiddleware
85 |
86 | application = NextJsMiddleware(django_asgi_app)
87 | ```
88 |
89 | The middleware automatically handles routing for Next.js assets and API requests, and supports WebSocket connections for fast refresh to work properly.
90 |
91 | You can use `NextJsMiddleware` with any ASGI application.
92 | For example, you can use it with `ProtocolTypeRouter`
93 | if you are using [Django Channels](https://channels.readthedocs.io/en/latest/):
94 |
95 | ```python
96 | application = NextJsMiddleware(
97 | ProtocolTypeRouter(
98 | {
99 | "http": django_asgi_app,
100 | "websocket": my_websocket_handler,
101 | # ...
102 | }
103 | )
104 | )
105 | ```
106 |
107 | If you're not using ASGI, add the following path to the beginning of `urls.py`:
108 |
109 | ```python
110 | urlpatterns = [
111 | path("", include("django_nextjs.urls")),
112 | ...
113 | ]
114 | ```
115 |
116 | > [!IMPORTANT]
117 | > Using ASGI is **required**
118 | > for [fast refresh](https://nextjs.org/docs/architecture/fast-refresh)
119 | > to work properly.
120 | > Without it, you'll need to manually refresh your browser
121 | > to see changes during development.
122 | >
123 | > To run your ASGI application, you can use an ASGI server
124 | > such as [Daphne](https://github.com/django/daphne)
125 | > or [Uvicorn](https://www.uvicorn.org/).
126 |
127 | > [!WARNING]
128 | > The `NextJSProxyHttpConsumer` and `NextJSProxyWebsocketConsumer` classes that were previously used for setup still exist and work, but they are deprecated and will be removed in the next major release. Please use the `NextJsMiddleware` approach described above.
129 |
130 | ## Setup Next.js URLs in production
131 |
132 | In production, use a reverse proxy like Nginx or Caddy.
133 |
134 | | URL | Action |
135 | |---------------------|-------------------------------------------------------------|
136 | | `/_next/static/...` | Serve `NEXTJS_PATH/.next/static` directory |
137 | | `/_next/...` | Proxy to the Next.js server (e.g., `http://127.0.0.1:3000`) |
138 | | `/next/...` | Serve `NEXTJS_PATH/public/next` directory |
139 |
140 | Example Nginx configuration:
141 |
142 | ```conf
143 | location /_next/static/ {
144 | alias NEXTJS_PATH/.next/static/;
145 | expires max;
146 | add_header Cache-Control "public";
147 | }
148 | location /_next/ {
149 | proxy_pass http://127.0.0.1:3000;
150 | proxy_set_header Host $http_host;
151 | proxy_set_header X-Real-IP $remote_addr;
152 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
153 | proxy_set_header X-Forwarded-Proto $scheme;
154 | }
155 | location /next/ {
156 | alias NEXTJS_PATH/public/next/;
157 | expires max;
158 | add_header Cache-Control "public";
159 | }
160 | ```
161 |
162 | ## Usage
163 |
164 | Start the Next.js server using `npm run dev` (development) or `npm run start` (production).
165 |
166 | Define Django URLs for your Next.js pages:
167 |
168 | ```python
169 | from django_nextjs.views import nextjs_page
170 |
171 | urlpatterns = [
172 | path("/my/page", nextjs_page(), name="my_page"),
173 |
174 | # With App Router streaming (recommended)
175 | path("/other/page", nextjs_page(stream=True), name="other_page"),
176 | ]
177 | ```
178 |
179 | ### The `stream` parameter
180 |
181 | If you're using the [Next.js App Router](https://nextjs.org/docs/app), you can enable streaming by setting the `stream` parameter to `True` in the `nextjs_page` function. This allows the HTML response to be streamed directly from the Next.js server to the client. This approach is particularly useful for server-side rendering with streaming support to display an [instant loading state](https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming#instant-loading-states) from the Next.js server while the content of a route segment loads.
182 |
183 | Currently, the default value for this parameter
184 | is set to `False` for backward compatibility.
185 | It will default to `True` in the next major release.
186 |
187 | ## Customizing the HTML response
188 |
189 | You can modify the HTML code that Next.js returns in your Django code.
190 |
191 | > [!WARNING]
192 | > This feature is not compatible with the Next.js App Router, and to use it,
193 | > you need to set the `stream` parameter to `False` in the `nextjs_page` function.
194 | > Because of these limitations, we do not recommend using this feature.
195 | > For more details, please refer to [this GitHub issue](https://github.com/QueraTeam/django-nextjs/issues/22).
196 |
197 | This is a common use case for avoiding duplicate code for the navbar and footer if you are using both Next.js and Django templates.
198 | Without it, you would have to write and maintain two separate versions
199 | of your navbar and footer (a Django template version and a Next.js version).
200 | However, you can simply create a Django template for your navbar and insert its code
201 | at the beginning of the `
` tag in the HTML returned by Next.js.
202 |
203 | To enable this feature, you need to customize the Next.js `pages/_document` file and make the following adjustments:
204 |
205 | - Add `id="__django_nextjs_body"` as the first attribute of the `` element.
206 | - Add `` as the first element inside ``.
207 | - Add `` as the last element inside ``.
208 |
209 | Read
210 | [this doc](https://nextjs.org/docs/pages/building-your-application/routing/custom-document)
211 | and customize your Next.js document:
212 |
213 | ```jsx
214 | // pages/_document.jsx (or .tsx)
215 | ...
216 |
217 |
218 |
219 |
220 |
221 |
222 | ...
223 | ```
224 |
225 | Write a Django template that extends `django_nextjs/document_base.html`:
226 |
227 | ```django
228 | {% extends "django_nextjs/document_base.html" %}
229 |
230 |
231 | {% block head %}
232 |
233 | {{ block.super }}
234 |
235 | {% endblock %}
236 |
237 |
238 | {% block body %}
239 | ... the content you want to place at the beginning of "body" tag ...
240 | ... e.g. include the navbar template ...
241 | {{ block.super }}
242 | ... the content you want to place at the end of "body" tag ...
243 | ... e.g. include the footer template ...
244 | {% endblock %}
245 | ```
246 |
247 | Pass the template name to `nextjs_page`:
248 |
249 | ```python
250 | from django_nextjs.views import nextjs_page
251 |
252 | urlpatterns = [
253 | path("/my/page", nextjs_page(template_name="path/to/template.html"), name="my_page"),
254 | ]
255 | ```
256 |
257 | ## Notes
258 |
259 | - Place Next.js [public](https://nextjs.org/docs/app/api-reference/file-conventions/public-folder) files in the `public/next` subdirectory.
260 | - Ensure all your middlewares are [async-capable](https://docs.djangoproject.com/en/dev/topics/http/middleware/#asynchronous-support).
261 | - Set `APPEND_SLASH = False` in `settings.py` to avoid redirect loops, and don't add trailing slashes to Next.js paths.
262 | - Implement an API to pass data between Django and Next.js.
263 | You can use Django REST Framework or GraphQL.
264 | - This package doesn't start Next.js - you'll need to run it separately.
265 |
266 | ## Settings
267 |
268 | You can configure `django-nextjs` using the `NEXTJS_SETTINGS` dictionary in your Django settings file.
269 | The default settings are:
270 |
271 | ```python
272 | NEXTJS_SETTINGS = {
273 | "nextjs_server_url": "http://127.0.0.1:3000",
274 | "ensure_csrf_token": True,
275 | "public_subdirectory": "/next",
276 | }
277 | ```
278 |
279 | ### `nextjs_server_url`
280 |
281 | The URL of the Next.js server (started by `npm run dev` or `npm run start`)
282 |
283 | ### `ensure_csrf_token`
284 |
285 | If the user does not have a CSRF token, ensure that one is generated and included in the initial request to the Next.js server by calling Django's `django.middleware.csrf.get_token`. If `django.middleware.csrf.CsrfViewMiddleware` is installed, the initial response will include a `Set-Cookie` header to persist the CSRF token value on the client. This behavior is enabled by default.
286 |
287 | > [!TIP]
288 | > **The use case for this option**
289 | >
290 | > You may need to issue GraphQL POST requests to fetch data in Next.js `getServerSideProps`. If this is the user's first request, there will be no CSRF cookie, causing the request to fail since GraphQL uses POST even for data fetching.
291 | > In this case, this option solves the issue,
292 | > and as long as `getServerSideProps` functions are side-effect free (i.e., they don't use HTTP unsafe methods or GraphQL mutations), it should be fine from a security perspective. Read more [here](https://docs.djangoproject.com/en/3.2/ref/csrf/#is-posting-an-arbitrary-csrf-token-pair-cookie-and-post-data-a-vulnerability).
293 |
294 | ### `public_subdirectory`
295 |
296 | Use this option to set a custom path instead of `/next` inside the Next.js
297 | [`public` directory](https://nextjs.org/docs/app/api-reference/file-conventions/public-folder).
298 | For example, you can set this option to `/static-next`
299 | and place the Next.js static files in the `public/static-next` directory.
300 | You should also update the production reverse proxy configuration accordingly.
301 |
302 | ## Contributing
303 |
304 | We welcome contributions from the community! Here's how to get started:
305 |
306 | 1. Install development dependencies: `pip install -e '.[dev]'`
307 | 2. Set up pre-commit hooks: `pre-commit install`
308 | 3. Make your changes and submit a pull request.
309 |
310 | Love django-nextjs? Give a star 🌟 on GitHub to help the project grow!
311 |
312 | ## License
313 |
314 | MIT - See [LICENSE](https://github.com/QueraTeam/django-nextjs/blob/main/LICENSE) for details.
315 |
--------------------------------------------------------------------------------
/django_nextjs/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "3.3.0"
2 |
--------------------------------------------------------------------------------
/django_nextjs/app_settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from django.conf import settings
4 |
5 | NEXTJS_SETTINGS = getattr(settings, "NEXTJS_SETTINGS", {})
6 |
7 | NEXTJS_SERVER_URL = NEXTJS_SETTINGS.get("nextjs_server_url", "http://127.0.0.1:3000")
8 | ENSURE_CSRF_TOKEN = NEXTJS_SETTINGS.get("ensure_csrf_token", True)
9 | PUBLIC_SUBDIRECTORY = NEXTJS_SETTINGS.get("public_subdirectory", "/next")
10 |
--------------------------------------------------------------------------------
/django_nextjs/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class DjangoNextJSConfig(AppConfig):
5 | name = "django_nextjs"
6 | verbose_name = "Django NextJS"
7 |
--------------------------------------------------------------------------------
/django_nextjs/asgi.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import functools
3 | import typing
4 | from abc import ABC, abstractmethod
5 | from typing import Optional
6 | from urllib.parse import urlparse
7 |
8 | import aiohttp
9 | import websockets
10 | from django.conf import settings
11 | from websockets import Data
12 | from websockets.asyncio.client import ClientConnection
13 |
14 | from django_nextjs.app_settings import NEXTJS_SERVER_URL, PUBLIC_SUBDIRECTORY
15 | from django_nextjs.exceptions import NextJsImproperlyConfigured
16 |
17 | # https://github.com/encode/starlette/blob/b9db010d49cfa33d453facde56e53a621325c720/starlette/types.py
18 | Scope = typing.MutableMapping[str, typing.Any]
19 | Message = typing.MutableMapping[str, typing.Any]
20 | Receive = typing.Callable[[], typing.Awaitable[Message]]
21 | Send = typing.Callable[[Message], typing.Awaitable[None]]
22 | ASGIApp = typing.Callable[[Scope, Receive, Send], typing.Awaitable[None]]
23 |
24 |
25 | class StopReceiving(Exception):
26 | pass
27 |
28 |
29 | class NextJsProxyBase(ABC):
30 | scope: Scope
31 | send: Send
32 |
33 | def __init__(self):
34 | if not settings.DEBUG:
35 | raise NextJsImproperlyConfigured("This proxy is for development only.")
36 |
37 | async def __call__(self, scope: Scope, receive: Receive, send: Send):
38 | self.scope = scope
39 | self.send = send
40 |
41 | while True:
42 | message = await receive()
43 | try:
44 | await self.handle_message(message)
45 | except StopReceiving:
46 | return # Exit cleanly
47 |
48 | @abstractmethod
49 | async def handle_message(self, message: Message): ...
50 |
51 | @classmethod
52 | def as_asgi(cls):
53 | """
54 | Return an ASGI v3 single callable that instantiates a consumer instance per scope.
55 | Similar in purpose to Django's as_view().
56 | """
57 |
58 | async def app(scope: Scope, receive: Receive, send: Send):
59 | consumer = cls()
60 | return await consumer(scope, receive, send)
61 |
62 | # take name and docstring from class
63 | functools.update_wrapper(app, cls, updated=())
64 | return app
65 |
66 |
67 | class NextJsHttpProxy(NextJsProxyBase):
68 | """
69 | Manages HTTP requests and proxies them to the Next.js development server.
70 |
71 | This handler is responsible for forwarding HTTP requests received by the
72 | Django application to the Next.js development server. It ensures that
73 | headers and body content are correctly relayed, and the response from
74 | the Next.js server is streamed back to the client. This is primarily
75 | used in development to serve Next.js assets through Django's ASGI server.
76 | """
77 |
78 | def __init__(self):
79 | super().__init__()
80 | self.body = []
81 |
82 | async def handle_message(self, message: Message) -> None:
83 | if message["type"] == "http.request":
84 | self.body.append(message.get("body", b""))
85 | if not message.get("more_body", False):
86 | await self.handle_request(b"".join(self.body))
87 | elif message["type"] == "http.disconnect":
88 | raise StopReceiving
89 |
90 | async def handle_request(self, body: bytes):
91 | url = NEXTJS_SERVER_URL + self.scope["path"] + "?" + self.scope["query_string"].decode()
92 | headers = {k.decode(): v.decode() for k, v in self.scope["headers"]}
93 |
94 | if session := self.scope.get("state", {}).get(NextJsMiddleware.HTTP_SESSION_KEY):
95 | session_is_temporary = False
96 | else:
97 | # If the shared session is not available, we create a temporary session.
98 | # This is typically the case when the ASGI server does not support the lifespan protocol (e.g. Daphne).
99 | session = aiohttp.ClientSession()
100 | session_is_temporary = True
101 |
102 | try:
103 | async with session.get(url, data=body, headers=headers) as response:
104 | nextjs_response_headers = [
105 | (name.encode(), value.encode())
106 | for name, value in response.headers.items()
107 | if name.lower() in ["content-type", "set-cookie"]
108 | ]
109 |
110 | await self.send(
111 | {"type": "http.response.start", "status": response.status, "headers": nextjs_response_headers}
112 | )
113 | async for data in response.content.iter_any():
114 | await self.send({"type": "http.response.body", "body": data, "more_body": True})
115 | await self.send({"type": "http.response.body", "body": b"", "more_body": False})
116 | finally:
117 | if session_is_temporary:
118 | await session.close()
119 |
120 |
121 | class NextJsWebSocketProxy(NextJsProxyBase):
122 | """
123 | Manages WebSocket connections and proxies messages between the client (browser)
124 | and the Next.js development server.
125 |
126 | This handler is essential for enabling real-time features like Hot Module
127 | Replacement (HMR) during development. It establishes a WebSocket connection
128 | to the Next.js server and relays messages back and forth, allowing for
129 | seamless updates in the browser when code changes are detected.
130 | """
131 |
132 | nextjs_connection: Optional[ClientConnection]
133 | nextjs_listener_task: Optional[asyncio.Task]
134 |
135 | def __init__(self):
136 | super().__init__()
137 | self.nextjs_connection = None
138 | self.nextjs_listener_task = None
139 |
140 | async def handle_message(self, message: Message) -> None:
141 | if message["type"] == "websocket.connect":
142 | await self.connect()
143 | elif message["type"] == "websocket.receive":
144 | if not self.nextjs_connection:
145 | await self.send({"type": "websocket.close"})
146 | elif data := message.get("text", message.get("bytes")):
147 | await self.receive(self.nextjs_connection, data=data)
148 | elif message["type"] == "websocket.disconnect":
149 | await self.disconnect()
150 | raise StopReceiving
151 |
152 | async def connect(self):
153 | nextjs_websocket_url = f"ws://{urlparse(NEXTJS_SERVER_URL).netloc}{self.scope['path']}"
154 | try:
155 | self.nextjs_connection = await websockets.connect(nextjs_websocket_url)
156 | except:
157 | await self.send({"type": "websocket.close"})
158 | raise
159 | self.nextjs_listener_task = asyncio.create_task(self._receive_from_nextjs_server(self.nextjs_connection))
160 | await self.send({"type": "websocket.accept"})
161 |
162 | async def _receive_from_nextjs_server(self, nextjs_connection: ClientConnection):
163 | """
164 | Listens for messages from the Next.js development server and forwards them to the browser.
165 | """
166 | try:
167 | async for message in nextjs_connection:
168 | if isinstance(message, bytes):
169 | await self.send({"type": "websocket.send", "bytes": message})
170 | elif isinstance(message, str):
171 | await self.send({"type": "websocket.send", "text": message})
172 | except websockets.ConnectionClosedError:
173 | await self.send({"type": "websocket.close"})
174 |
175 | async def receive(self, nextjs_connection: ClientConnection, data: Data):
176 | """
177 | Handles incoming messages from the browser and forwards them to the Next.js development server.
178 | """
179 | try:
180 | await nextjs_connection.send(data)
181 | except websockets.ConnectionClosed:
182 | await self.send({"type": "websocket.close"})
183 |
184 | async def disconnect(self):
185 | """
186 | Performs cleanup when the WebSocket connection is closed, either by the browser or by us.
187 | """
188 |
189 | if self.nextjs_listener_task:
190 | self.nextjs_listener_task.cancel()
191 | self.nextjs_listener_task = None
192 |
193 | if self.nextjs_connection:
194 | await self.nextjs_connection.close()
195 | self.nextjs_connection = None
196 |
197 |
198 | class NextJsMiddleware:
199 | """
200 | ASGI middleware that integrates Django and Next.js applications.
201 |
202 | - Intercepts requests to Next.js paths (like '/_next', '/__next', '/next') in development
203 | mode and forwards them to the Next.js development server. This works as a transparent
204 | proxy, handling both HTTP requests and WebSocket connections (for Hot Module Replacement).
205 |
206 | - Manages an aiohttp ClientSession throughout the application lifecycle using the ASGI
207 | lifespan protocol. The session is created during application startup and properly closed
208 | during shutdown, ensuring efficient reuse of HTTP connections when communicating with the
209 | Next.js server.
210 | """
211 |
212 | HTTP_SESSION_KEY = "django_nextjs_http_session"
213 |
214 | def __init__(self, inner_app: ASGIApp) -> None:
215 | self.inner_app = inner_app
216 |
217 | if settings.DEBUG:
218 | # Pre-create ASGI callables for the consumers
219 | self.nextjs_http_proxy = NextJsHttpProxy.as_asgi()
220 | self.nextjs_websocket_proxy = NextJsWebSocketProxy.as_asgi()
221 |
222 | async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
223 |
224 | # --- Lifespan Handling ---
225 | if scope["type"] == "lifespan":
226 | # Handle lifespan events (startup/shutdown)
227 | return await self._handle_lifespan(scope, receive, send)
228 |
229 | # --- Next.js Route Handling (DEBUG mode only) ---
230 | elif settings.DEBUG:
231 | path = scope.get("path", "")
232 | if any(path.startswith(prefix) for prefix in ["/_next", "/__next", PUBLIC_SUBDIRECTORY]):
233 | if scope["type"] == "http":
234 | return await self.nextjs_http_proxy(scope, receive, send)
235 | elif scope["type"] == "websocket":
236 | return await self.nextjs_websocket_proxy(scope, receive, send)
237 |
238 | # --- Default Handling ---
239 | return await self.inner_app(scope, receive, send)
240 |
241 | async def _handle_lifespan(self, scope: Scope, receive: Receive, send: Send) -> None:
242 | """
243 | Handle the lifespan protocol for the ASGI application.
244 | This is where we can manage the lifecycle of the application.
245 |
246 | https://asgi.readthedocs.io/en/latest/specs/lifespan.html
247 | """
248 |
249 | async def lifespan_receive() -> Message:
250 | message = await receive()
251 | if message["type"] == "lifespan.startup" and "state" in scope:
252 | # Create a new aiohttp ClientSession and store it in the scope's state.
253 | # This session will be used for making HTTP requests to the Next.js server
254 | # during the application's lifetime.
255 | scope["state"][self.HTTP_SESSION_KEY] = aiohttp.ClientSession()
256 | return message
257 |
258 | async def lifespan_send(message: Message) -> None:
259 | if message["type"] == "lifespan.shutdown.complete" and "state" in scope:
260 | # Clean up resources after inner app shutdown is complete
261 | http_session: typing.Optional[aiohttp.ClientSession] = scope["state"].get(self.HTTP_SESSION_KEY)
262 | if http_session:
263 | await http_session.close()
264 | await send(message)
265 |
266 | try:
267 | await self.inner_app(scope, lifespan_receive, lifespan_send)
268 | except:
269 | # The underlying app has not implemented the lifespan protocol, so we run our own implementation.
270 | while True:
271 | lifespan_message = await lifespan_receive()
272 | if lifespan_message["type"] == "lifespan.startup":
273 | await lifespan_send({"type": "lifespan.startup.complete"})
274 | elif lifespan_message["type"] == "lifespan.shutdown":
275 | await lifespan_send({"type": "lifespan.shutdown.complete"})
276 | return
277 |
--------------------------------------------------------------------------------
/django_nextjs/exceptions.py:
--------------------------------------------------------------------------------
1 | class NextJsImproperlyConfigured(Exception):
2 | pass
3 |
--------------------------------------------------------------------------------
/django_nextjs/proxy.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import urllib.request
3 | from http.client import HTTPResponse
4 |
5 | from django import http
6 | from django.conf import settings
7 | from django.views import View
8 |
9 | from django_nextjs.app_settings import NEXTJS_SERVER_URL
10 | from django_nextjs.asgi import NextJsHttpProxy, NextJsWebSocketProxy
11 | from django_nextjs.exceptions import NextJsImproperlyConfigured
12 |
13 | logger = logging.getLogger(__name__)
14 |
15 |
16 | class NextJSProxyHttpConsumer(NextJsHttpProxy):
17 | @classmethod
18 | def as_asgi(cls):
19 | # Use "logging" instead of "warnings" module because of this issue:
20 | # https://github.com/django/daphne/issues/352
21 | logger.warning(
22 | "NextJSProxyHttpConsumer is deprecated and will be removed in the next major release. "
23 | "Use NextJsMiddleware from django_nextjs.asgi instead.",
24 | )
25 | return super().as_asgi()
26 |
27 |
28 | class NextJSProxyWebsocketConsumer(NextJsWebSocketProxy):
29 | @classmethod
30 | def as_asgi(cls):
31 | # Use "logging" instead of "warnings" module because of this issue:
32 | # https://github.com/django/daphne/issues/352
33 | logger.warning(
34 | "NextJSProxyWebsocketConsumer is deprecated and will be removed in the next major release. "
35 | "Use NextJsMiddleware from django_nextjs.asgi instead.",
36 | )
37 | return super().as_asgi()
38 |
39 |
40 | class NextJSProxyView(View):
41 | """
42 | Proxies /next..., /_next..., /__nextjs... requests to Next.js server in development environment.
43 | Source: https://github.com/yourlabs/djnext/blob/master/src/djnext/views.py
44 |
45 | - This is a normal django view.
46 | - Supports streaming response.
47 | """
48 |
49 | def dispatch(self, request, *args, **kwargs):
50 | if not settings.DEBUG:
51 | raise NextJsImproperlyConfigured("This proxy is for development only.")
52 | return super().dispatch(request, *args, **kwargs)
53 |
54 | def get(self, request):
55 | url = NEXTJS_SERVER_URL + request.path + "?" + request.GET.urlencode()
56 | headers = {}
57 | for header in ["Cookie", "User-Agent"]:
58 | if header in request.headers:
59 | headers[header] = request.headers[header]
60 |
61 | urllib_response = urllib.request.urlopen(urllib.request.Request(url, headers=headers))
62 |
63 | return http.StreamingHttpResponse(
64 | self._iter_content(urllib_response), headers={"Content-Type": urllib_response.headers.get("Content-Type")}
65 | )
66 |
67 | def _iter_content(self, urllib_response: HTTPResponse):
68 | while True:
69 | chunk = urllib_response.read(urllib_response.length or 1)
70 | if not chunk:
71 | urllib_response.close()
72 | break
73 | yield chunk
74 |
--------------------------------------------------------------------------------
/django_nextjs/render.py:
--------------------------------------------------------------------------------
1 | from http.cookies import Morsel
2 | from typing import Optional
3 | from urllib.parse import quote
4 |
5 | import aiohttp
6 | from asgiref.sync import sync_to_async
7 | from django.conf import settings
8 | from django.core.handlers.asgi import ASGIRequest
9 | from django.http import HttpRequest, HttpResponse, StreamingHttpResponse
10 | from django.middleware.csrf import get_token as get_csrf_token
11 | from django.template.loader import render_to_string
12 | from multidict import MultiMapping
13 |
14 | from .app_settings import ENSURE_CSRF_TOKEN, NEXTJS_SERVER_URL
15 | from .asgi import NextJsMiddleware
16 | from .utils import filter_mapping_obj
17 |
18 | morsel = Morsel()
19 |
20 |
21 | def _get_render_context(html: str, extra_context: Optional[dict] = None):
22 | a = html.find("")
23 | b = html.find('")],
34 | "section2": html[a + len("") : b],
35 | "section3": html[b:c],
36 | "section4": html[c:d],
37 | "section5": html[d:],
38 | },
39 | }
40 |
41 |
42 | def _get_nextjs_request_cookies(request: HttpRequest):
43 | """
44 | Ensure we always send a CSRF cookie to Next.js server (if there is none in `request` object, generate one)
45 | """
46 | unreserved_cookies = {k: v for k, v in request.COOKIES.items() if k and not morsel.isReservedKey(k)}
47 | if ENSURE_CSRF_TOKEN is True and settings.CSRF_COOKIE_NAME not in unreserved_cookies:
48 | unreserved_cookies[settings.CSRF_COOKIE_NAME] = get_csrf_token(request)
49 | return unreserved_cookies
50 |
51 |
52 | def _get_nextjs_request_headers(request: HttpRequest, headers: Optional[dict] = None):
53 | # These headers are used by Next.js to indicate if a request is expecting a full HTML
54 | # response, or an RSC response.
55 | server_component_headers = filter_mapping_obj(
56 | request.headers,
57 | selected_keys=[
58 | "Rsc",
59 | "Next-Router-State-Tree",
60 | "Next-Router-Prefetch",
61 | "Next-Url",
62 | "Cookie",
63 | "Accept-Encoding",
64 | ],
65 | )
66 |
67 | return {
68 | "x-real-ip": request.headers.get("X-Real-Ip", "") or request.META.get("REMOTE_ADDR", ""),
69 | "user-agent": request.headers.get("User-Agent", ""),
70 | **server_component_headers,
71 | **(headers or {}),
72 | }
73 |
74 |
75 | def _get_nextjs_response_headers(headers: MultiMapping[str]) -> dict:
76 | return filter_mapping_obj(
77 | headers,
78 | selected_keys=[
79 | "Location",
80 | "Vary",
81 | "Content-Type",
82 | "Set-Cookie",
83 | "Link",
84 | "Cache-Control",
85 | "Connection",
86 | "Date",
87 | "Keep-Alive",
88 | ],
89 | )
90 |
91 |
92 | async def _render_nextjs_page_to_string(
93 | request: HttpRequest,
94 | template_name: str = "",
95 | context: Optional[dict] = None,
96 | using: Optional[str] = None,
97 | allow_redirects: bool = False,
98 | headers: Optional[dict] = None,
99 | ) -> tuple[str, int, dict[str, str]]:
100 | page_path = quote(request.path_info.lstrip("/"))
101 | params = [(k, v) for k in request.GET.keys() for v in request.GET.getlist(k)]
102 |
103 | # Get HTML from Next.js server
104 | async with aiohttp.ClientSession(
105 | cookies=_get_nextjs_request_cookies(request),
106 | headers=_get_nextjs_request_headers(request, headers),
107 | ) as session:
108 | async with session.get(
109 | f"{NEXTJS_SERVER_URL}/{page_path}", params=params, allow_redirects=allow_redirects
110 | ) as response:
111 | html = await response.text()
112 | response_headers = _get_nextjs_response_headers(response.headers)
113 |
114 | # Apply template rendering (HTML customization) if template_name is provided
115 | if template_name:
116 | render_context = _get_render_context(html, context)
117 | if render_context is not None:
118 | html = await sync_to_async(render_to_string)(
119 | template_name, context=render_context, request=request, using=using
120 | )
121 | return html, response.status, response_headers
122 |
123 |
124 | async def render_nextjs_page_to_string(
125 | request: HttpRequest,
126 | template_name: str = "",
127 | context: Optional[dict] = None,
128 | using: Optional[str] = None,
129 | allow_redirects: bool = False,
130 | headers: Optional[dict] = None,
131 | ):
132 | html, _, _ = await _render_nextjs_page_to_string(
133 | request,
134 | template_name,
135 | context,
136 | using=using,
137 | allow_redirects=allow_redirects,
138 | headers=headers,
139 | )
140 | return html
141 |
142 |
143 | async def render_nextjs_page(
144 | request: HttpRequest,
145 | template_name: str = "",
146 | context: Optional[dict] = None,
147 | using: Optional[str] = None,
148 | allow_redirects: bool = False,
149 | headers: Optional[dict] = None,
150 | ):
151 | content, status, response_headers = await _render_nextjs_page_to_string(
152 | request,
153 | template_name,
154 | context,
155 | using=using,
156 | allow_redirects=allow_redirects,
157 | headers=headers,
158 | )
159 | return HttpResponse(content=content, status=status, headers=response_headers)
160 |
161 |
162 | async def stream_nextjs_page(
163 | request: ASGIRequest,
164 | allow_redirects: bool = False,
165 | headers: Optional[dict] = None,
166 | ):
167 | """
168 | Stream a Next.js page response.
169 | This function is used to stream the response from a Next.js server.
170 | """
171 | page_path = quote(request.path_info.lstrip("/"))
172 | params = [(k, v) for k in request.GET.keys() for v in request.GET.getlist(k)]
173 | next_url = f"{NEXTJS_SERVER_URL}/{page_path}"
174 |
175 | if session := request.scope.get("state", {}).get(NextJsMiddleware.HTTP_SESSION_KEY):
176 | session_is_temporary = False
177 | else:
178 | # If the shared session is not available, we create a temporary session.
179 | # This is typically the case when the ASGI server does not support the lifespan protocol (e.g. Daphne).
180 | session = aiohttp.ClientSession()
181 | session_is_temporary = True
182 |
183 | try:
184 | nextjs_response = await session.get(
185 | next_url,
186 | params=params,
187 | allow_redirects=allow_redirects,
188 | cookies=_get_nextjs_request_cookies(request),
189 | headers=_get_nextjs_request_headers(request, headers),
190 | )
191 | response_headers = _get_nextjs_response_headers(nextjs_response.headers)
192 |
193 | async def stream_nextjs_response():
194 | try:
195 | async for chunk in nextjs_response.content.iter_any():
196 | yield chunk
197 | finally:
198 | await nextjs_response.release()
199 | if session_is_temporary:
200 | await session.close()
201 |
202 | return StreamingHttpResponse(
203 | stream_nextjs_response(),
204 | status=nextjs_response.status,
205 | headers=response_headers,
206 | )
207 | except:
208 | if session_is_temporary:
209 | await session.close()
210 | raise
211 |
--------------------------------------------------------------------------------
/django_nextjs/templates/django_nextjs/document_base.html:
--------------------------------------------------------------------------------
1 | {{ django_nextjs__.section1|safe }}{% block head %}{{ django_nextjs__.section2|safe }}{% endblock head %}{{ django_nextjs__.section3|safe }}{% block body %}{{ django_nextjs__.section4|safe }}{% endblock body %}{{ django_nextjs__.section5|safe }}
2 |
--------------------------------------------------------------------------------
/django_nextjs/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.urls import re_path
3 |
4 | from .proxy import NextJSProxyView
5 |
6 | app_name = "django_nextjs"
7 | urlpatterns = []
8 |
9 | if settings.DEBUG:
10 | # only in dev environment
11 | urlpatterns.append(re_path(r"^(?:_next|__nextjs|next).*$", NextJSProxyView.as_view()))
12 |
--------------------------------------------------------------------------------
/django_nextjs/utils.py:
--------------------------------------------------------------------------------
1 | import typing
2 |
3 | Key = typing.TypeVar("Key")
4 | Value = typing.TypeVar("Value")
5 |
6 |
7 | def filter_mapping_obj(mapping_obj: typing.Mapping[Key, Value], *, selected_keys: typing.Iterable) -> dict[Key, Value]:
8 | """
9 | Selects the items in a mapping object (dict, etc.)
10 | """
11 |
12 | return {key: mapping_obj[key] for key in selected_keys if key in mapping_obj}
13 |
--------------------------------------------------------------------------------
/django_nextjs/views.py:
--------------------------------------------------------------------------------
1 | from typing import Optional
2 |
3 | from .render import render_nextjs_page, stream_nextjs_page
4 |
5 |
6 | def nextjs_page(
7 | *,
8 | stream: bool = False,
9 | template_name: str = "",
10 | context: Optional[dict] = None,
11 | using: Optional[str] = None,
12 | allow_redirects: bool = False,
13 | headers: Optional[dict] = None,
14 | ):
15 | if stream and (template_name or context or using):
16 | raise ValueError("When 'stream' is set to True, you should not use 'template_name', 'context', or 'using'")
17 |
18 | async def view(request, *args, **kwargs):
19 | if stream:
20 | return await stream_nextjs_page(request=request, allow_redirects=allow_redirects, headers=headers)
21 |
22 | return await render_nextjs_page(
23 | request=request,
24 | template_name=template_name,
25 | context=context,
26 | using=using,
27 | allow_redirects=allow_redirects,
28 | headers=headers,
29 | )
30 |
31 | return view
32 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.pytest.ini_options]
2 | DJANGO_SETTINGS_MODULE = "tests.settings"
3 | norecursedirs = ".git"
4 | django_find_project = false
5 | pythonpath = ["."]
6 |
7 | [tool.black]
8 | line-length = 120
9 | include = '\.pyi?$'
10 | exclude = '/\..+/'
11 |
12 | [tool.isort]
13 | profile = "black"
14 | line_length = 120
15 | skip_gitignore = true
16 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from setuptools import find_packages, setup
4 |
5 | from django_nextjs import __version__
6 |
7 | # allow setup.py to be run from any path
8 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir)))
9 |
10 | dev_requirements = [
11 | "pre-commit",
12 | "pytest>=7",
13 | "pytest-cov",
14 | "pytest-django",
15 | "pytest-asyncio",
16 | "black",
17 | "isort",
18 | ]
19 |
20 | LONG_DESCRIPTION = """
21 | `django-nextjs` allows Django and Next.js pages to work together seamlessly.
22 | It enables you to add Next.js pages to an existing Django project
23 | or gradually migrate your frontend to Next.js.
24 |
25 | **For full documentation, usage examples, and advanced configuration,
26 | please visit the GitHub repository:**
27 | [django-nextjs](https://github.com/QueraTeam/django-nextjs)
28 | """
29 |
30 | setup(
31 | name="django-nextjs",
32 | version=__version__,
33 | description="Integrate Next.js into your Django project",
34 | long_description=LONG_DESCRIPTION.strip(),
35 | long_description_content_type="text/markdown",
36 | keywords=["django", "next", "nextjs", "django-nextjs"],
37 | author="Quera Team",
38 | url="https://github.com/QueraTeam/django-nextjs",
39 | download_url="https://github.com/QueraTeam/django-nextjs",
40 | packages=find_packages(".", include=("django_nextjs", "django_nextjs.*")),
41 | include_package_data=True,
42 | install_requires=["Django >= 4.2", "aiohttp", "websockets"],
43 | extras_require={"dev": dev_requirements},
44 | classifiers=[
45 | "Development Status :: 5 - Production/Stable",
46 | "Environment :: Web Environment",
47 | "Framework :: Django",
48 | "Framework :: Django :: 4.2",
49 | "Framework :: Django :: 5.0",
50 | "Framework :: Django :: 5.1",
51 | "Framework :: Django :: 5.2",
52 | "Intended Audience :: Developers",
53 | "Operating System :: OS Independent",
54 | "Programming Language :: Python",
55 | "Programming Language :: Python :: 3.9",
56 | "Programming Language :: Python :: 3.10",
57 | "Programming Language :: Python :: 3.11",
58 | "Programming Language :: Python :: 3.12",
59 | "Programming Language :: Python :: 3.13",
60 | ],
61 | )
62 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/QueraTeam/django-nextjs/650e280f7adde29eabc3cefe1c657f28b47de84d/tests/__init__.py
--------------------------------------------------------------------------------
/tests/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | BASE_DIR = os.path.dirname(os.path.abspath(__file__))
4 | SECRET_KEY = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
5 |
6 | DEBUG = True
7 | USE_TZ = False
8 |
9 | INSTALLED_APPS = [
10 | "django.contrib.admin",
11 | "django.contrib.auth",
12 | "django.contrib.contenttypes",
13 | "django.contrib.sessions",
14 | "django.contrib.messages",
15 | "django_nextjs.apps.DjangoNextJSConfig",
16 | ]
17 |
18 | MIDDLEWARE = [
19 | "django.contrib.sessions.middleware.SessionMiddleware",
20 | "django.middleware.common.CommonMiddleware",
21 | "django.middleware.csrf.CsrfViewMiddleware",
22 | "django.contrib.auth.middleware.AuthenticationMiddleware",
23 | "django.contrib.messages.middleware.MessageMiddleware",
24 | ]
25 |
26 | # ROOT_URLCONF = "tests.urls"
27 |
28 | TEMPLATES = [
29 | {
30 | "BACKEND": "django.template.backends.django.DjangoTemplates",
31 | "DIRS": [os.path.join(BASE_DIR, "templates")],
32 | "APP_DIRS": True,
33 | "OPTIONS": {
34 | "context_processors": [
35 | "django.template.context_processors.debug",
36 | "django.template.context_processors.request",
37 | "django.contrib.auth.context_processors.auth",
38 | "django.contrib.messages.context_processors.messages",
39 | ],
40 | },
41 | },
42 | ]
43 |
44 | DATABASES = {
45 | "default": {
46 | "ENGINE": "django.db.backends.sqlite3",
47 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
48 | }
49 | }
50 |
51 | STATIC_URL = "/static/"
52 |
53 | NEXTJS_SETTINGS = {
54 | "nextjs_server_url": "http://127.0.0.1:3000",
55 | }
56 |
--------------------------------------------------------------------------------
/tests/templates/custom_document.html:
--------------------------------------------------------------------------------
1 | {% extends "django_nextjs/document_base.html" %}
2 | {% block head %}
3 | before_head
4 | {{ block.super }}
5 | after_head
6 | {% endblock %}
7 |
8 | {% block body %}
9 | {{ block.super }}
10 | {% endblock %}
11 |
--------------------------------------------------------------------------------
/tests/test_render.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import AsyncMock, MagicMock, patch
2 |
3 | import pytest
4 | from django.test import RequestFactory
5 | from django.utils.datastructures import MultiValueDict
6 |
7 | from django_nextjs.app_settings import NEXTJS_SERVER_URL
8 | from django_nextjs.render import _get_render_context, render_nextjs_page_to_string
9 | from django_nextjs.views import nextjs_page
10 |
11 |
12 | def test_get_render_context_empty_html():
13 | assert _get_render_context("") is None
14 |
15 |
16 | def test_get_render_context_html_without_children():
17 | assert _get_render_context("") is None
18 |
19 |
20 | def test_get_render_context_html_with_empty_sections():
21 | assert _get_render_context("") is None
22 |
23 |
24 | def test_get_render_context_html_with_incomplete_sections():
25 | assert (
26 | _get_render_context(
27 | """
28 | """
29 | )
30 | is None
31 | )
32 |
33 |
34 | def test_get_render_context_html_with_sections_and_content():
35 | html = """"""
36 | expected_context = {
37 | "django_nextjs__": {
38 | "section1": "",
39 | "section2": "",
40 | "section3": '',
41 | "section4": '',
42 | "section5": '',
43 | }
44 | }
45 | assert _get_render_context(html) == expected_context
46 |
47 | context = {"extra_context": "content"}
48 | assert _get_render_context(html, context) == {**expected_context, **context}
49 |
50 |
51 | @pytest.mark.asyncio
52 | async def test_nextjs_page(rf: RequestFactory):
53 | path = "random/path"
54 | params = MultiValueDict({"name": ["Adrian", "Simon"], "position": ["Developer"]})
55 | request = rf.get(f"/{path}", data=params)
56 | nextjs_response = ""
57 |
58 | with patch("aiohttp.ClientSession") as mock_session:
59 | with patch("aiohttp.ClientSession.get") as mock_get:
60 | mock_get.return_value.__aenter__.return_value.text = AsyncMock(return_value=nextjs_response)
61 | mock_get.return_value.__aenter__.return_value.status = 200
62 | mock_get.return_value.__aenter__.return_value.headers = {"Location": "target_value", "unimportant": ""}
63 | mock_session.return_value.__aenter__ = AsyncMock(return_value=MagicMock(get=mock_get))
64 |
65 | http_response = await nextjs_page(allow_redirects=True, headers={"extra": "headers"})(request)
66 |
67 | assert http_response.content == nextjs_response.encode()
68 | assert http_response.status_code == 200
69 | assert http_response.has_header("Location")
70 | assert http_response.has_header("unimportant") is False
71 |
72 | # Arguments passed to aiohttp.ClientSession.get
73 | args, kwargs = mock_get.call_args
74 | url = args[0]
75 | assert url == f"{NEXTJS_SERVER_URL}/{path}"
76 | assert [(k, v) for k in params.keys() for v in params.getlist(k)] == kwargs["params"]
77 | assert kwargs["allow_redirects"] is True
78 |
79 | args, kwargs = mock_session.call_args
80 | assert "csrftoken" in kwargs["cookies"]
81 | assert kwargs["headers"]["user-agent"] == ""
82 | assert kwargs["headers"]["x-real-ip"] == "127.0.0.1"
83 | assert kwargs["headers"]["extra"] == "headers"
84 |
85 |
86 | @pytest.mark.asyncio
87 | async def test_set_csrftoken(rf: RequestFactory):
88 | def get_mock_request():
89 | return rf.get("/random/path")
90 |
91 | async def get_mock_response(request: RequestFactory):
92 | with patch("aiohttp.ClientSession") as mock_session:
93 | with patch("aiohttp.ClientSession.get") as mock_get:
94 | mock_get.return_value.__aenter__.return_value.text = AsyncMock(return_value="")
95 | mock_get.return_value.__aenter__.return_value.status = 200
96 | mock_session.return_value.__aenter__ = AsyncMock(return_value=MagicMock(get=mock_get))
97 | return await nextjs_page(allow_redirects=True)(request), mock_session
98 |
99 | # User does not have csrftoken and django-nextjs is not configured to guarantee one
100 | with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", False):
101 | http_request = get_mock_request()
102 | _, mock_session = await get_mock_response(http_request)
103 | args, kwargs = mock_session.call_args
104 | # This triggers CsrfViewMiddleware to call response.set_cookie with updated csrftoken value
105 | assert "CSRF_COOKIE_NEEDS_UPDATE" not in http_request.META
106 | assert "csrftoken" not in kwargs["cookies"]
107 |
108 | # User does not have csrftoken and django-nextjs is configured to guarantee one
109 | with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", True):
110 | http_request = get_mock_request()
111 | _, mock_session = await get_mock_response(http_request)
112 | args, kwargs = mock_session.call_args
113 | assert "CSRF_COOKIE_NEEDS_UPDATE" in http_request.META
114 | assert "csrftoken" in kwargs["cookies"]
115 |
116 | # User has csrftoken and django-nextjs is not configured to guarantee one
117 | with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", False):
118 | http_request = get_mock_request()
119 | http_request.COOKIES["csrftoken"] = "whatever"
120 | _, mock_session = await get_mock_response(http_request)
121 | args, kwargs = mock_session.call_args
122 | assert "CSRF_COOKIE_NEEDS_UPDATE" not in http_request.META
123 | assert "csrftoken" in kwargs["cookies"]
124 |
125 | # User has csrftoken and django-nextjs is configured to guarantee one
126 | with patch("django_nextjs.render.ENSURE_CSRF_TOKEN", True):
127 | http_request = get_mock_request()
128 | http_request.COOKIES["csrftoken"] = "whatever"
129 | _, mock_session = await get_mock_response(http_request)
130 | args, kwargs = mock_session.call_args
131 | assert "CSRF_COOKIE_NEEDS_UPDATE" not in http_request.META
132 | assert "csrftoken" in kwargs["cookies"]
133 |
134 |
135 | @pytest.mark.asyncio
136 | async def test_render_nextjs_page_to_string(rf: RequestFactory):
137 | request = rf.get(f"/random/path")
138 | nextjs_response = """"""
139 |
140 | with patch("aiohttp.ClientSession") as mock_session:
141 | with patch("aiohttp.ClientSession.get") as mock_get:
142 | mock_get.return_value.__aenter__.return_value.text = AsyncMock(return_value=nextjs_response)
143 | mock_session.return_value.__aenter__ = AsyncMock(return_value=MagicMock(get=mock_get))
144 |
145 | response_text = await render_nextjs_page_to_string(request, template_name="custom_document.html")
146 | assert "before_head" in response_text
147 | assert "after_head" in response_text
148 |
--------------------------------------------------------------------------------