Next.js(SSG) inside Frappe - Working on Frappe Cloud

We recently explored building Next.js inside Frappe while developing documentation pages for HUF.

This post shares what we tried, what actually worked, what didn’t

Our goal was to serve a modern documentation site built with Next.js (we tried Nextra) while keeping everything inside a Frappe app:

  • No separate Node hosting
  • No external reverse proxy
  • Must work on Frappe Cloud

1. Static Generation (SSG)

Build process:

  • next
  • Output: fully static HTML, CSS, JS

This gave us:

  • Static index.html files per route
  • _next/static/* assets

2. Served static output via Frappe

We placed the generated output inside:

apps/huf/www/huf/docs/ 

Then we wrote a custom Page Renderer in Frappe.

Custom Page Renderer

  • Serve static Next.js / Nextra generated HTML files inside Frappe.
  • Avoid interfering with assets (_next, CSS, JS, images, fonts).
  • Support clean URLs like:/huf/docs/
  • Minimal dynamic logic (only Jinja injection if needed)

DocsRenderer (with detailed comments)

"""
Custom Page Renderer for Documentation.

Serves static Next.js exported HTML files for /huf/docs
WITHOUT interfering with static assets.
"""

import os
import frappe
from frappe.website.page_renderers.base_renderer import BaseRenderer

class DocsRenderer(BaseRenderer):

    def can_render(self):
        """
        Determine whether this renderer should handle the current request.

        Logic:
        1. Get the request path from frappe.local.request
        2. Ignore Next.js Server Components queries (_rsc=)
        3. Never render assets (CSS, JS, images, fonts, etc.)
        4. Only render routes that match /huf/docs or /huf/docs/<anything>
        """

        req = frappe.local.request
        if not req:
            return False

        path = req.path.lstrip("/")  # "/huf/docs/docs/installation" → "huf/docs/docs/installation"

        # Ignore React Server Components and other internal Next.js queries
        if "_rsc=" in req.query_string.decode():
            return False

        # Ignore static assets served elsewhere
        if path.startswith(("assets/", "files/")):
            return False

        # Ignore direct asset requests
        if path.endswith((
            ".css", ".js", ".map", ".png", ".jpg", ".svg",
            ".woff", ".woff2", ".ttf", ".eot", ".txt"
        )):
            return False

        # Only render documentation pages
        return path == "huf/docs" or path.startswith("huf/docs/")

    def render(self):
        """
        Serve the correct static HTML file for the requested documentation route.

        Logic:
        1. Map the request path to the static HTML file in www/huf/docs
        2. If path is root (/huf/docs), serve index.html
        3. For subpaths (/huf/docs/docs/installation), map to docs/installation/index.html
        4. If HTML file not found, fallback to root index.html (SPA behavior)
        5. Read HTML and remove any Jinja raw tags
        6. Return HTML as a Frappe response
        """

        req = frappe.local.request
        path = req.path.lstrip("/")
        app_path = frappe.get_app_path("huf")
        docs_root = os.path.join(app_path, "www", "huf", "docs")

        # Resolve which HTML file to serve
        if path == "huf/docs":
            html_file = os.path.join(docs_root, "index.html")
        else:
            sub_path = path[len("huf/docs/"):]  # e.g., "docs/installation"
            html_file = os.path.join(docs_root, sub_path, "index.html")

        # SPA fallback
        if not os.path.exists(html_file):
            html_file = os.path.join(docs_root, "index.html")
            if not os.path.exists(html_file):
                return self.build_response(
                    "<h1>Documentation not found</h1>",
                    http_status_code=404,
                    headers={"Content-Type": "text/html; charset=utf-8"},
                )

        # Read static HTML
        with open(html_file, "r", encoding="utf-8") as f:
            html = f.read()

        # Remove Jinja guards
        html = html.replace("{% raw %}", "").replace("{% endraw %}", "")

        return self.build_response(
            html,
            headers={"Content-Type": "text/html; charset=utf-8"},
        )

Key points

  • Why can_render ignores assets
    Prevents CSS/JS/images from being returned as HTML.
  • Why {% raw %} is removed
    Static exports may include Jinja guards to prevent parsing — they must be stripped at runtime.
  • Folder mapping logic
    • /huf/docsindex.html
    • /huf/docs/docs/installationdocs/installation/index.html

Setup Steps (Example: my-next-app)

Below are the exact steps to reproduce this pattern.

1. Create the Next.js app

npx create-next-app my-next-app 
cd my-next-app 
npm install

2. Configure next.config.js

  • output: 'export' → static HTML
  • trailingSlash: true → folder-based routing
  • assetPrefix → ensures assets load correctly inside Frappe
// LOCAL_SERVE=true disables basePath / assetPrefix for local testing and development
const isLocalServe = process.env.NODE_ENV === 'development'

const basePath = isLocalServe ? '' : '/huf/docs'
const assetPrefix = isLocalServe ? undefined : '/assets/huf/_next'

export default withNextra({
  output: 'export',
  basePath,
  assetPrefix,
  trailingSlash: true,
  images: {
    unoptimized: true
  }
})

3. Build Configuration

We handle HTML and assets separately.

  • HTML → goes into www/ (served by Page Renderer)
  • _next assets → go into public/ (served directly by Frappe)

update build script in package.json:

{
  "scripts": {
    "build": "next build \
      && rm -rf ../huf/public/huf/_next \
      && mkdir -p ../huf/public/huf/_next \
      && cp -r out/_next/* ../huf/public/huf/_next/ \
      && rm -rf ../huf/www/huf/docs \
      && mkdir -p ../huf/www/huf/docs \
      && cp -r out/* ../huf/www/huf/docs/"
  }
}

4. Route Rules in hooks.py

website_route_rules = [
{“from_route”: “/huf/docs”, “to_route”: “huf/docs”},
{“from_route”: “/huf/docs/<path:path>”, “to_route”: “huf/docs/<path:path>”},
]

5. Custom Renderer, example : renderer.py in www

Sample

For Reference : Huf Docs Renderer

This route can also be configured as the Home Page in Website Settings, allowing the Next.js site to act as the default landing page of the Frappe application.

4 Likes