Repository: wagtail/wagtail
Pull request #13084: Headless / API usage docs improvements
Author: login: thibaudcolas
Labels: name: component:API
description: REST API - endpoints, serializers, API configuration
name: Headless
description: Headless CMS usage, API-first, decoupled frontend, content as a service
Created: 2025-05-02T08:31:02Z | Merged: 2025-05-06T12:29:15Z

Includes a number of documentation improvements specifically for people who want to retrieve CMS data via an API:

- Docs on our `expand_db_html`(#2695). We could also have a built-in serializer for this but having the docs already helps I expect.
- Examples of using `image.get_renditions` to provide renditions via an API endpoint.
- A new "how-to" documentation page to use Wagtail with [django-ninja](https://django-ninja.dev/). I think it’s interesting for us to have some usage information for Ninja in our docs because it’s a practical example of:
  - A way to generate API endpoints that support OpenAPI docs (and includes a docs viewer as a built-in feature)
  - How there’s nothing special about Wagtail and our DRF integration, people can make their own integration without it being a ton of work.
- And finally a mention of limiting data (#12604)



Diff:
diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index dd677526ddb7..cb8bdfc2a176 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -68,6 +68,10 @@ Changelog
  * Docs: Clarify that `insert_editor_js` hook applies to all core editing/creation views (LB (Ben) Johnston)
  * Docs: Clarify requirement for non-page models using model mixins to be registered as snippets (Sage Abdullah)
  * Docs: Clarify that `Page.get_url_parts` will return a tuple, not `None` for `NoReverseMatch` errors (Arthur Tripp)
+ * Docs: Add how-to Django Ninja to API documentation (Thibaud Colas)
+ * Docs: Document the `expand_db_html` utility function to create HTML ready for display (Thibaud Colas)
+ * Docs: Mention `expand_db_html` usage for rich text in REST framework API (Thibaud Colas)
+ * Docs: Further document usage of API `?limit` with `WAGTAILAPI_LIMIT_MAX` (Thibaud Colas)
  * Maintenance: Migrate away from deprecated Sass import rules to module system (Srishti Jaiswal)
  * Maintenance: Apply Sass mixed declarations migration in preparation for CSS nesting (Prabhpreet Kaur)
  * Maintenance: Refactor styles for Draftail, minimap, and comments to fix remaining Sass migration warnings (Thibaud Colas)
diff --git a/docs/advanced_topics/api/django-ninja.md b/docs/advanced_topics/api/django-ninja.md
new file mode 100644
index 000000000000..33a22810f594
--- /dev/null
+++ b/docs/advanced_topics/api/django-ninja.md
@@ -0,0 +1,249 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+While Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+from wagtail.models import Page
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path("api/", api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    path("", include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=list[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=list[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every parameter of the `list_pages` function as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+from blog.models import BlogPage
+
+
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect that our response may return multiple page types, we use the type hint union syntax to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+from home.models import HomePage
+
+
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema | HomePageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` class attribute annotation to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page. Here is the final version of `BasePageSchema`:
+
+```python
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+    content_type: str
+
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.specific_class._meta.model_name
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: list[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> list[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the `Field` class if the `BlogPage` class had a method to retrieve author names directly: `authors: list[str] = Field([], alias="get_author_names")`.
+
+### Rich text in the API
+
+Rich text fields in Wagtail use a specific internal format, described in [](../../extending/rich_text_internals). They can be added to the schema as `str`, but it’s often more useful for the API to provide a “display” representation, where references to pages and images are replaced with URLs.
+This can also be done with [Ninja resolvers](https://django-ninja.dev/guides/response/#resolvers). Here is an example with the `HomePageSchema`:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class HomePageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["homepage"]
+    body: str
+
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+    @staticmethod
+    def resolve_body(page: HomePage, context) -> str:
+        return expand_db_html(page.body)
+```
+
+Here, `body` is defined as a `str`, and the resolver uses the `expand_db_html` function to convert the internal representation to HTML.
+
+### Images in the API
+
+We can use a similar technique for images, combining resolvers and aliases to generate the data.
+We use the [`get_renditions()` method](image_renditions_multiple) to retrieve the formatted images, and a custom `RenditionSchema` to define their API representation.
+
+```python
+from wagtail.images.models import AbstractRendition
+
+
+class RenditionSchema(ModelSchema):
+    # We need to use the Field / alias API for properties
+    url: str = Field(None, alias="file.url")
+    alt: str = Field(None, alias="alt")
+
+    class Config:
+        model = AbstractRendition
+        model_fields = [
+            "width",
+            "height",
+        ]
+```
+
+On the `BlogPageSchema`, we define our image field as: `main_image: list[RenditionSchema] = []`. Then add the resolver for it:
+
+```python
+    @staticmethod
+    def resolve_main_image(page: BlogPage) -> list[AbstractRendition]:
+        filters = [
+            "fill-800x600|format-webp",
+            "fill-800x600",
+        ]
+        if image := page.main_image():
+            return image.get_renditions(*filters).values()
+        return []
+```
+
+In JSON, our `main_image` is now represented as an array, where each item is an object with `url`, `alt`, `width`, and `height` properties.
+
+## OpenAPI documentation
+
+Django Ninja generates [OpenAPI](https://swagger.io/specification/) documentation automatically, based on the defined operations and schemas.
+This also includes a documentation viewer, with support to try out the API directly from the browser. With the above example, you can try accessing the docs at `/api/docs`.
+
+To make the most of this capability, consider the supported [operations parameters](https://django-ninja.dev/reference/operations-parameters/).
diff --git a/docs/advanced_topics/api/index.md b/docs/advanced_topics/api/index.md
index 194589af502c..eda53fe1b533 100644
--- a/docs/advanced_topics/api/index.md
+++ b/docs/advanced_topics/api/index.md
@@ -2,7 +2,7 @@
 
 # Wagtail API
 
-The API module provides a public-facing, JSON-formatted API to allow retrieving
+Wagtail includes a built-in API module that provides a public-facing, JSON-formatted API to allow retrieving
 content as raw field data. This is useful for cases like serving content to
 non-web clients (such as a mobile phone app) or pulling content out of Wagtail
 for use in another site.
@@ -10,10 +10,13 @@ for use in another site.
 See [RFC 8: Wagtail API](https://github.com/wagtail/rfcs/blob/main/text/008-wagtail-api.md#12---stable-and-unstable-versions)
 for full details on our stabilization policy.
 
+Wagtail is built on Django, so you can also use other Django solutions for building APIs such as [with Django Ninja](api_ninja) or [with GraphQL](https://github.com/torchbox/wagtail-grapple).
+
 ```{toctree}
 ---
 maxdepth: 2
 ---
 v2/configuration
 v2/usage
+django-ninja
 ```
diff --git a/docs/advanced_topics/api/v2/configuration.md b/docs/advanced_topics/api/v2/configuration.md
index a02d065fcf85..1aff86d73e2c 100644
--- a/docs/advanced_topics/api/v2/configuration.md
+++ b/docs/advanced_topics/api/v2/configuration.md
@@ -231,6 +231,29 @@ This adds two fields to the API (other fields omitted for brevity):
 }
 ```
 
+### Rich text in the API
+
+In the above example, we serialize the `body` field using Wagtail’s storage format for rich text, described in [](../../../extending/rich_text_internals). This is useful when the API client will directly manipulate the identifiers referencing external data within rich text, such as fetching more data about page links or images by ID.
+
+It’s also often useful for the API to directly provide a “display” representation, similarly to the `|richtext` template filter. This can be done with a custom serializer:
+
+```python
+from rest_framework.fields import CharField
+from wagtail.rich_text import expand_db_html
+
+
+class RichTextSerializer(CharField):
+    def to_representation(self, instance):
+        representation = super().to_representation(instance)
+        return expand_db_html(representation)
+```
+
+We can then change our `api_fields` definition so `body` uses this new serializer:
+
+```python
+APIField('body', serializer=RichTextSerializer()),
+```
+
 (api_v2_images)=
 
 ### Images in the API
@@ -366,3 +389,4 @@ endpoints.
 
 This allows you to change the maximum number of results a user can request at a
 time. This applies to all endpoints. Set to `None` for no limit.
+Combine with [`?limit` and `?offset` query parameters](apiv2_pagination) to retrieve the desired number of results.
diff --git a/docs/advanced_topics/api/v2/usage.md b/docs/advanced_topics/api/v2/usage.md
index 756797809baf..9c4f7c7835a7 100644
--- a/docs/advanced_topics/api/v2/usage.md
+++ b/docs/advanced_topics/api/v2/usage.md
@@ -133,6 +133,8 @@ those endpoints. But for projects that have customized image/document models,
 the `api_fields` attribute can be used to export any custom fields into the
 API.
 
+(apiv2_pagination)=
+
 ### Pagination
 
 The number of items in the response can be changed by using the `?limit`
diff --git a/docs/advanced_topics/headless.md b/docs/advanced_topics/headless.md
index 04e46d45820b..d9824500447e 100644
--- a/docs/advanced_topics/headless.md
+++ b/docs/advanced_topics/headless.md
@@ -61,7 +61,7 @@ GraphQL is a newer API technology than REST. Unlike REST, GraphQL isn't an archi
 
 #### GraphQL libraries compatible with Wagtail
 
--   [wagtail-grapple](https://github.com/GrappleGQL/wagtail-grapple) by GrappleGQL
+-   [wagtail-grapple](https://github.com/torchbox/wagtail-grapple) by Torchbox
 -   [strawberry-wagtail](https://github.com/patrick91/strawberry-wagtail) by Patrick Arminio
 
 ## Functionality
@@ -179,4 +179,4 @@ There is a plugin available currently that automatically pings Netlify to build
 -   [Official Wagtail documentation on building a public-facing API](api)
 -   Wagtail API tutorial from [LearnWagtail.com](https://learnwagtail.com/tutorials/how-to-enable-the-v2-api-to-create-a-headless-cms/)
 -   [Using Wagtail, NuxtJS and Vuetify to build a fast and secure static site](https://www.nurseadvance.com/articles/using-wagtail-nuxtjs-and-vuetify-build-fast-and-secure-static-site/)
--   [Going Headless with Wagtail, Nuxt.js and GraphQL (PDF)](https://trs.jpl.nasa.gov/bitstream/handle/2014/54119/CL%2320-3292.pdf?sequence=1)
+-   [Going Headless with Wagtail, Nuxt.js and GraphQL (PDF)](https://dataverse.jpl.nasa.gov/dataset.xhtml?persistentId=hdl:2014/54119&version=2.0)
diff --git a/docs/extending/rich_text_internals.md b/docs/extending/rich_text_internals.md
index a11340e1a88f..e5710f8902d6 100644
--- a/docs/extending/rich_text_internals.md
+++ b/docs/extending/rich_text_internals.md
@@ -56,6 +56,19 @@ A number of additional constraints apply to `<a linktype="...">` and `<embed emb
 -   `embed` elements must use XML self-closing tag syntax (those that end in `/>` instead of a closing `</embed>` tag)
 -   The only HTML entities permitted in attribute values are `&lt;`, `&gt;`, `&amp;` and `&quot;`
 
+(rich_text_manual_conversion)=
+
+### Manual conversion
+
+For scenarios where neither a `RichTextBlock` nor the `|richtext` filter are appropriate, directly use Wagtail’s `expand_db_html` utility function. It expands internal references to CMS content (images, embeds, documents, page links), to create HTML ready for display.
+
+```python
+from wagtail.rich_text import expand_db_html
+
+# Converts the stored rich text data format to HTML suitable for rendering.
+expand_db_html(page.body)
+```
+
 ## The feature registry
 
 Any app within your project can define extensions to Wagtail's rich text handling, such as new `linktype` and `embedtype` rules. An object known as the _feature registry_ serves as a central source of truth about how rich text should behave. This object can be accessed through the [Register Rich Text Features](register_rich_text_features) hook, which is called on startup to gather all definitions relating to rich text:
diff --git a/docs/reference/settings.md b/docs/reference/settings.md
index f33dc0282d3f..8b654a811ecd 100644
--- a/docs/reference/settings.md
+++ b/docs/reference/settings.md
@@ -803,7 +803,7 @@ Required when using frontend cache invalidation, used to generate absolute URLs
 WAGTAILAPI_LIMIT_MAX = 500
 ```
 
-Default is 20, used to change the maximum number of results a user can request at a time, set to `None` for no limit.
+Default is 20, used to change the maximum number of results a user can request at a time, set to `None` for no limit. Once this is set, combine with [`?limit` and `?offset` query parameters](apiv2_pagination) to retrieve the desired number of results.
 
 ### `WAGTAILAPI_SEARCH_ENABLED`
 
diff --git a/docs/releases/7.0.md b/docs/releases/7.0.md
index f1bede5f3a6c..9b98069be253 100644
--- a/docs/releases/7.0.md
+++ b/docs/releases/7.0.md
@@ -31,6 +31,14 @@ We have a new pagination interface for all listing views and most choosers, incl
 
 This release adds a new "Locale" column to the listings and choosers of translatable models, making it easier to filter and sort by locale. The current content's locale is applied in choosers by default, with the ability to clear the locale filter. This feature was developed by Dan Braghis and Sage Abdullah.
 
+### How-to guide for Django Ninja
+
+While Wagtail provides a [built-in API module](api) based on the popular Django REST Framework, it is possible to use other approaches.
+This release adds a new [](api_ninja) guide, to demonstrate basic usage of Wagtail with [Django Ninja](https://django-ninja.dev/), which leverages type hints and Pydantic for data validation.
+The guide covers common requirements beyond initial setup, like rich text, image renditions, and generation of API documentation.
+
+This documentation was contributed by Thibaud Colas with support from Sage Abdullah.
+
 ### Other features
 
  * Add `WAGTAIL_` prefix to Wagtail-specific tag settings (Aayushman Singh)
@@ -83,6 +91,9 @@ This release adds a new "Locale" column to the listings and choosers of translat
  * Clarify requirement for non-page models using model mixins to be registered as snippets (Sage Abdullah)
  * Document the `page.move` method from django-treebeard (Shlomo Markowitz)
  * Clarify that `Page.get_url_parts` will return a tuple, not `None` for `NoReverseMatch` errors (Arthur Tripp)
+ * Document the [`expand_db_html` utility function](rich_text_manual_conversion) to create HTML ready for display (Thibaud Colas)
+ * Mention `expand_db_html` usage for rich text in [REST framework API](api_v2_configuration) (Thibaud Colas)
+ * Docs: Further document usage of API `?limit` with `WAGTAILAPI_LIMIT_MAX` (Thibaud Colas)
 
 ### Maintenance
 


Reviews:
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T10:09:26Z
user: login: laymonage
body: Thank you @thibaudcolas, this works great! I have minor suggestions, and I think you've addressed some of them in the new commits you pushed.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:04:21Z
user: login: laymonage
body: None of the remaining comments are particularly blockers, so I'm giving it a preliminary approval 👍 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-06T11:19:06Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:42:39Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:43:44Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:45:23Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:50:44Z
user: login: tomdyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:56:28Z
user: login: tomdyson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T11:57:24Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T11:59:15Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:01:25Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:02:38Z
user: login: thibaudcolas
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-06T12:03:47Z

Review comments:
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: ```suggestion
    path("", include(wagtail_urls)),
```
created_at: 2025-05-02T14:42:11Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: ```suggestion
    path("api/", api.urls),
```
created_at: 2025-05-02T14:42:43Z
author_association: MEMBER
diff_hunk: @@ -16,4 +16,5 @@ maxdepth: 2
 ---
 v2/configuration
 v2/usage
+django-ninja
path: docs/advanced_topics/api/index.md
user: login: laymonage
body: Now that the API section is not only about the built-in DRF module, is it worth having a new sentence in the summary above that mentions something about Ninja despite not being a built-in solution? Something like

> Wagtail is built on Django, so you can also use other Django solutions for building APIs such as Django Ninja.

And maybe mention wagtail-grapple too if we want to clarify that Ninja/REST is not particularly special.

Also, the first sentence can probably be "Wagtail includes a built-in API module that provides a public-facing..."
created_at: 2025-05-06T09:10:27Z
author_association: MEMBER
diff_hunk: @@ -231,6 +231,28 @@ This adds two fields to the API (other fields omitted for brevity):
 }
 ```
 
+### Rich text in the API
+
+In the above example, we serialize the `body` field using Wagtail’s storage format for rich text, described in [](../../../extending/rich_text_internals). This is useful when the API client will directly manipulate the identifiers referencing external data within rich text, such as fetching more data about page links or images by ID.
+
+It’s also often useful for the API to directly provide a “display” representation, similarly to the `|richtext` template filter. This can be done with a custom serializer:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class RichTextSerializer(fields.CharField):
+    def to_representation(self, instance):
+        representation = super().to_representation(instance)
+        return expand_db_html(representation)
+```
+
+We can then change our `api_fields` definition so `body` uses this new serializer:
+
+```python
+APIField('body', serializer=RichTextSerializer()),
path: docs/advanced_topics/api/v2/configuration.md
user: login: laymonage
body: Do we want an example of using `expand_db_html` for `RichTextBlock` as well?
created_at: 2025-05-06T09:23:24Z
author_association: MEMBER
diff_hunk: @@ -231,6 +231,28 @@ This adds two fields to the API (other fields omitted for brevity):
 }
 ```
 
+### Rich text in the API
+
+In the above example, we serialize the `body` field using Wagtail’s storage format for rich text, described in [](../../../extending/rich_text_internals). This is useful when the API client will directly manipulate the identifiers referencing external data within rich text, such as fetching more data about page links or images by ID.
+
+It’s also often useful for the API to directly provide a “display” representation, similarly to the `|richtext` template filter. This can be done with a custom serializer:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class RichTextSerializer(fields.CharField):
path: docs/advanced_topics/api/v2/configuration.md
user: login: laymonage
body: The previous examples import the field classes directly, e.g. `from rest_framework.fields import DateField`, so we probably want to do the same here? Or add the `from rest_framework import fields` import
created_at: 2025-05-06T09:29:53Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: https://github.com/wagtail/wagtail/blob/5f5b0d63247bc37fcc5ea0a7d2e5918b3b5129f8/wagtail/project_template/project_name/urls.py#L31

```suggestion
    path("", include(wagtail_urls)),
```
created_at: 2025-05-06T09:39:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Missing `Page` import
created_at: 2025-05-06T09:40:00Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: For a second I thought there was a `list_pages` parameter

```suggestion
Ninja treats every parameter of the `list_pages` function as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
```
created_at: 2025-05-06T09:42:16Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Should there be a paragraph or sentence about trying out the `list_pages` endpoint by visiting `/api/pages/` before continuing with the detail view?
created_at: 2025-05-06T09:50:58Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Missing `BlogPage` import
created_at: 2025-05-06T09:55:47Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Are we really adding a "parameter", or a schema field? How do we add it?
created_at: 2025-05-06T10:25:34Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Maybe show an example for one of these? To me it wasn't immediately clear where I should add these `content_type` annotations, especially when the previous paragraph says that it's a parameter
created_at: 2025-05-06T10:28:52Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Just a thought, accessing `page.content_type` would trigger a database query to fetch the `ContentType` from the database if `select_related()` was not used. I wonder if using something like `page.specific_class._meta.model_name` (or [`page.specific_class._meta.label`](https://docs.djangoproject.com/en/5.1/ref/models/options/#django.db.models.Options.label)) would be better, as `specific_class` makes use of `ContentType`'s manager cache.
created_at: 2025-05-06T10:36:19Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` Union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: List[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> List[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the Pydantic `Field` function if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: `Field` is not a function

```suggestion
This could also be done with a Pydantic `Field` if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
```

Also, the previous examples have the import come from Ninja, i.e. `from ninja import Field`. I wasn't sure it was just reexported from Pydantic until I checked it on VSCode. So maybe


```suggestion
This could also be done with the `Field` class if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
```
created_at: 2025-05-06T10:42:18Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` Union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: List[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> List[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the Pydantic `Field` function if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
+
+### Rich text in the API
+
+Rich text fields in Wagtail use a specific internal format, described in [](../../extending/rich_text_internals). They can be added to the schema as `str`, but it’s often more useful for the API to provide a “display” representation, where references to pages and images are replaced with URLs.
+This can also be done with [Ninja resolvers](https://django-ninja.dev/guides/response/#resolvers). Here is an example with the `HomePageSchema`:
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: There's already a link for resolvers in a previous paragraph. I'm OK with linking again but if it wasn't intentional then we should remove it
created_at: 2025-05-06T10:57:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` Union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: List[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> List[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the Pydantic `Field` function if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
+
+### Rich text in the API
+
+Rich text fields in Wagtail use a specific internal format, described in [](../../extending/rich_text_internals). They can be added to the schema as `str`, but it’s often more useful for the API to provide a “display” representation, where references to pages and images are replaced with URLs.
+This can also be done with [Ninja resolvers](https://django-ninja.dev/guides/response/#resolvers). Here is an example with the `HomePageSchema`:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class HomePageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["homepage"]
+    body: str
+
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+    @staticmethod
+    def resolve_body(page: HomePage, context) -> str:
+        return expand_db_html(page.body)
+```
+
+Here, `body` is defined as a `str`, and the resolver uses the `expand_db_html` function to convert the internal representation to HTML.
+
+### Images in the API
+
+We can use a similar technique for images, combining resolvers and aliases to generate the data.
+We use the [`get_renditions()` method](image_renditions_multiple) to retrieve the formatted images, and a custom `RenditionSchema` to define their API representation.
+
+```python
+from wagtail.images.models import AbstractRendition
+
+
+class RenditionSchema(ModelSchema):
+    # We need to use the Field / alias API for properties
+    url: str = Field(None, alias="file.url")
+    alt: str = Field(None, alias="alt")
+
+    class Config:
+        model = AbstractRendition
+        model_fields = [
+            "width",
+            "height",
+        ]
+```
+
+On the `BlogPageSchema`, we define our image field as: `main_image: List[RenditionSchema] = []`. Then add the resolver for it:
+
+```python
+    @staticmethod
+    def resolve_main_image(page: BlogPage) -> List[AbstractRendition]:
+        filters = [
+            "fill-800x600|format-webp",
+            "fill-800x600",
+        ]
+        if image := page.main_image():
+            return image.get_renditions(*filters).values()
+        return []
+```
+
+In JSON, our `main_image` is now represented as an array, where each item is an object with `url`, `alt`, `width`, and `height` properties.
+
+## OpenAPI documentation
+
+Django Ninja generates [OpenAPI](https://swagger.io/specification/) documentation automatically, based on the defined operations and schemas.
+This also includes a documentation viewer, with support to try out the API directly from the browser.
path: docs/advanced_topics/api/django-ninja.md
user: login: laymonage
body: Maybe add a suggestion to check it out?

```suggestion
This also includes a documentation viewer, with support to try out the API directly from the browser. With the above example, you can try accessing the docs at `/api/docs`.
```

(Annoyingly, the path doesn't have a trailing slash and adding it results in 404 in my case for some reason.)
created_at: 2025-05-06T11:03:37Z
author_association: MEMBER
diff_hunk: @@ -231,6 +231,28 @@ This adds two fields to the API (other fields omitted for brevity):
 }
 ```
 
+### Rich text in the API
+
+In the above example, we serialize the `body` field using Wagtail’s storage format for rich text, described in [](../../../extending/rich_text_internals). This is useful when the API client will directly manipulate the identifiers referencing external data within rich text, such as fetching more data about page links or images by ID.
+
+It’s also often useful for the API to directly provide a “display” representation, similarly to the `|richtext` template filter. This can be done with a custom serializer:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class RichTextSerializer(fields.CharField):
+    def to_representation(self, instance):
+        representation = super().to_representation(instance)
+        return expand_db_html(representation)
+```
+
+We can then change our `api_fields` definition so `body` uses this new serializer:
+
+```python
+APIField('body', serializer=RichTextSerializer()),
path: docs/advanced_topics/api/v2/configuration.md
user: login: thibaudcolas
body: That’d be a nice extra improvement!
created_at: 2025-05-06T11:42:39Z
author_association: MEMBER
diff_hunk: @@ -231,6 +231,28 @@ This adds two fields to the API (other fields omitted for brevity):
 }
 ```
 
+### Rich text in the API
+
+In the above example, we serialize the `body` field using Wagtail’s storage format for rich text, described in [](../../../extending/rich_text_internals). This is useful when the API client will directly manipulate the identifiers referencing external data within rich text, such as fetching more data about page links or images by ID.
+
+It’s also often useful for the API to directly provide a “display” representation, similarly to the `|richtext` template filter. This can be done with a custom serializer:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class RichTextSerializer(fields.CharField):
path: docs/advanced_topics/api/v2/configuration.md
user: login: thibaudcolas
body: Sounds good!
created_at: 2025-05-06T11:43:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: Ah good catch.
created_at: 2025-05-06T11:45:22Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: Let’s!
created_at: 2025-05-06T11:50:44Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,237 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
path: docs/advanced_topics/api/django-ninja.md
user: login: tomdyson
body: ```suggestion
While Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
```

'Even though' makes it sound like it should be a surprise :)
created_at: 2025-05-06T11:56:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,237 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+from wagtail.models import Page
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path("api/", api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    path("", include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every parameter of the `list_pages` function as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+from blog.models import BlogPage
+
+
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
path: docs/advanced_topics/api/django-ninja.md
user: login: tomdyson
body: ```suggestion
To reflect that our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
```
created_at: 2025-05-06T11:57:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: Ty!
created_at: 2025-05-06T11:59:15Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` Union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: List[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> List[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the Pydantic `Field` function if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: Thank you!
created_at: 2025-05-06T12:01:25Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` Union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: List[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> List[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the Pydantic `Field` function if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
+
+### Rich text in the API
+
+Rich text fields in Wagtail use a specific internal format, described in [](../../extending/rich_text_internals). They can be added to the schema as `str`, but it’s often more useful for the API to provide a “display” representation, where references to pages and images are replaced with URLs.
+This can also be done with [Ninja resolvers](https://django-ninja.dev/guides/response/#resolvers). Here is an example with the `HomePageSchema`:
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: I thought it would be worthwhile to link again to make this more self-contained
created_at: 2025-05-06T12:02:38Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,230 @@
+(api_ninja)=
+
+# How to set up Django Ninja
+
+Even though Wagtail provides a [built-in API module](api) based on Django REST Framework, it is possible to use other API frameworks.
+Here is information on usage with [Django Ninja](https://django-ninja.dev/), an API framework built on Python type hints and [Pydantic](https://docs.pydantic.dev/latest/), which includes built-in support for OpenAPI schemas.
+
+## Basic configuration
+
+Install `django-ninja`. Optionally you can also add `ninja` to your `INSTALLED_APPS` to avoid loading static files externally when using the OpenAPI documentation viewer.
+
+### Create the API
+
+We will create a new `api.py` module next to the existing `urls.py` file in the project root, instantiate the router.
+
+```python
+# api.py
+from typing import List, Union, Literal
+from django.http import HttpRequest
+from django.shortcuts import get_object_or_404
+from ninja import Field, ModelSchema, NinjaAPI
+
+api = NinjaAPI()
+```
+
+Next, register the URLs so Django can route requests into the API. To test this is working, navigate to `/api/docs`, which displays the OpenAPI documentation (with no available endpoints yet).
+
+```python
+# urls.py
+
+from .api import api
+
+urlpatterns = [
+    ...
+
+    path('api/', api.urls),
+
+    ...
+
+    # Ensure that the api line appears above the default Wagtail page serving route
+    re_path(r'^', include(wagtail_urls)),
+]
+```
+
+### Our first endpoint
+
+We will create a simple endpoint that returns a list of all pages in the site. We use the `@api.get` operation decorator to define what route the endpoint is available at, and how to format the response: here, using a custom schema we create.
+
+```python
+# api.py
+
+
+class BasePageSchema(ModelSchema):
+    url: str = Field(None, alias="get_url")
+
+    class Config:
+        model = Page
+        model_fields = [
+            "id",
+            "title",
+            "slug",
+        ]
+
+
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest"):
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Our custom `BasePageSchema` combines two techniques: [schema generation from Django models](https://django-ninja.dev/guides/response/django-pydantic/), and calculated fields with [aliases](https://django-ninja.dev/guides/response/#aliases). Here, we use an alias to retrieve the page URL.
+
+We can also add an extra `child_of: int = None` parameter to our endpoint to filter the pages by their parent:
+
+```python
+@api.get("/pages/", response=List[BasePageSchema])
+def list_pages(request: "HttpRequest", child_of: int = None):
+    if child_of:
+        return get_object_or_404(Page, id=child_of).get_children().live().public()
+    # Exclude the page tree root.
+    return Page.objects.live().public().exclude(id=1)
+```
+
+Ninja treats every `list_pages` parameter as a query parameter. It uses the provided type hint to parse the value, validate it, and generate the OpenAPI schema.
+
+### Adding custom page fields
+
+Next, let’s add a "detail" API endpoint to return a single page of a specific type. We can use the [path parameters](https://django-ninja.dev/guides/input/path-params/) from Ninja to retrieve our `page_id`.
+We also create a new schema for a specific page type: here, `BlogPage`, with `BasePageSchema` as a base.
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+
+@api.get("/pages/{page_id}/", response=BlogPageSchema)
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+This works well, with the endpoint now returning generic `Page` fields and the `BlogPage` introduction.
+But for sites where all page content is served via an API, it could become tedious to create new endpoints for every page type.
+
+### Combining multiple schemas
+
+To reflect our response may return multiple page types, we use the `Union` type hint to combine multiple schemas.
+This allows us to return different page types from the same endpoint.
+Here is an example with an additional schema for our `HomePage` type:
+
+```python
+class HomePageSchema(BasePageSchema, ModelSchema):
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+@api.get("/pages/{page_id}/", response=Union[BlogPageSchema, HomePageSchema])
+def get_page(request: "HttpRequest", page_id: int):
+    return get_object_or_404(Page, id=page_id).specific
+```
+
+With this in place, we are still missing a way to determine which of the schemas to use for a given page.
+We want to do this by page type, adding an extra `content_type` parameter to our schemas.
+
+- For `BasePageSchema`, we define `content_type: str`, as any page type can use this base.
+- For `HomePageSchema`, we set `content_type: Literal["homepage"]`.
+- And for `BlogPageSchema`, we set `content_type: Literal["blogpage"]`.
+
+All we need now is to add a [resolver](https://django-ninja.dev/guides/response/#resolvers) calculated field to the `BasePageSchema`, to return the correct content type for each page.
+
+```python
+    @staticmethod
+    def resolve_content_type(page: Page) -> str:
+        return page.content_type.model
+```
+
+With this in place, Pydantic is able to validate the page data returned in `get_page` according to one of the schemas in the `response` Union.
+It then serializes the data according to the specific schema.
+
+### Nested data
+
+Where the page schema references data in separate models, rather than creating new endpoints, we can add the data directly to the page schema.
+Here is an example, adding blog page authors (a snippet with a `ParentalManyToManyField`):
+
+```python
+class BlogPageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["blogpage"]
+    authors: List[str] = []
+
+    class Config(BasePageSchema.Config):
+        model = BlogPage
+        model_fields = [
+            "intro",
+        ]
+
+    @staticmethod
+    def resolve_authors(page: BlogPage, context) -> List[str]:
+        return [author.name for author in page.authors.all()]
+```
+
+This could also be done with the Pydantic `Field` function if the `BlogPage` class had a method to retrieve author names directly: `authors: List[str] = Field([], alias="get_author_names")`.
+
+### Rich text in the API
+
+Rich text fields in Wagtail use a specific internal format, described in [](../../extending/rich_text_internals). They can be added to the schema as `str`, but it’s often more useful for the API to provide a “display” representation, where references to pages and images are replaced with URLs.
+This can also be done with [Ninja resolvers](https://django-ninja.dev/guides/response/#resolvers). Here is an example with the `HomePageSchema`:
+
+```python
+from wagtail.rich_text import expand_db_html
+
+
+class HomePageSchema(BasePageSchema, ModelSchema):
+    content_type: Literal["homepage"]
+    body: str
+
+    class Config(BasePageSchema.Config):
+        model = HomePage
+
+    @staticmethod
+    def resolve_body(page: HomePage, context) -> str:
+        return expand_db_html(page.body)
+```
+
+Here, `body` is defined as a `str`, and the resolver uses the `expand_db_html` function to convert the internal representation to HTML.
+
+### Images in the API
+
+We can use a similar technique for images, combining resolvers and aliases to generate the data.
+We use the [`get_renditions()` method](image_renditions_multiple) to retrieve the formatted images, and a custom `RenditionSchema` to define their API representation.
+
+```python
+from wagtail.images.models import AbstractRendition
+
+
+class RenditionSchema(ModelSchema):
+    # We need to use the Field / alias API for properties
+    url: str = Field(None, alias="file.url")
+    alt: str = Field(None, alias="alt")
+
+    class Config:
+        model = AbstractRendition
+        model_fields = [
+            "width",
+            "height",
+        ]
+```
+
+On the `BlogPageSchema`, we define our image field as: `main_image: List[RenditionSchema] = []`. Then add the resolver for it:
+
+```python
+    @staticmethod
+    def resolve_main_image(page: BlogPage) -> List[AbstractRendition]:
+        filters = [
+            "fill-800x600|format-webp",
+            "fill-800x600",
+        ]
+        if image := page.main_image():
+            return image.get_renditions(*filters).values()
+        return []
+```
+
+In JSON, our `main_image` is now represented as an array, where each item is an object with `url`, `alt`, `width`, and `height` properties.
+
+## OpenAPI documentation
+
+Django Ninja generates [OpenAPI](https://swagger.io/specification/) documentation automatically, based on the defined operations and schemas.
+This also includes a documentation viewer, with support to try out the API directly from the browser.
path: docs/advanced_topics/api/django-ninja.md
user: login: thibaudcolas
body: Let’s. This is already an early step in the mini tutorial at the top of the page but certainly worth mentioning again.
created_at: 2025-05-06T12:03:47Z
author_association: MEMBER

PR comments:
user: login: lb-
created_at: 2025-05-05T03:59:05Z
body: I have not run the code examples locally but have read through and it all reads well and makes sense. An awesome update, thanks @thibaudcolas 
author_association: MEMBER
user: login: tomdyson
created_at: 2025-05-06T12:00:09Z
body: This is fantastic @thibaudcolas! I'll be referring to these docs in the next couple of weeks.
author_association: CONTRIBUTOR
user: login: thibaudcolas
created_at: 2025-05-07T06:28:05Z
body: @tomdyson thanks for the feedback, glad you like it! If you reuse this I’d recommend linking to the [/latest/ version of the Ninja page](https://docs.wagtail.org/en/latest/advanced_topics/api/django-ninja.html) for now as it’s likely we’ll make more changes in the coming weeks.
author_association: MEMBER