├── .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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | Next.js Pages 80 | 81 | 82 | Request 83 | for page 84 | (HTML) 85 | 86 | 87 | API 88 | Call 89 | 90 | 91 | 92 | 93 | /my/page 94 | /other/page 95 | 96 | 97 | Next.js internal paths 98 | 99 | 100 | /_next/static/chunks/30...4.js 101 | /_next/static/css/d2a...af1.css 102 | /_next/data/YCo...Yzl/fa/page.json 103 | /_next/... 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 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 | [![Tests status](https://github.com/QueraTeam/django-nextjs/workflows/tests/badge.svg)](https://github.com/QueraTeam/django-nextjs/actions) 4 | [![PyPI version](https://img.shields.io/pypi/v/django-nextjs.svg)](https://pypi.org/project/django-nextjs/) 5 | ![PyPI downloads](https://img.shields.io/pypi/dm/django-nextjs.svg) 6 | [![License: MIT](https://img.shields.io/github/license/QueraTeam/django-nextjs.svg)](https://github.com/QueraTeam/django-nextjs/blob/master/LICENSE) 7 | [![Code style: Black](https://img.shields.io/badge/code%20style-black-000000.svg)](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 | ![How it works in production](.github/assets/how-it-works-production.svg) 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 | --------------------------------------------------------------------------------