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.htmlfiles 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_renderignores 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/docs→index.html/huf/docs/docs/installation→docs/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 HTMLtrailingSlash: true→ folder-based routingassetPrefix→ 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) _nextassets → go intopublic/(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
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.