Why NextJS with Frappe is failing here
Next.js Inside Frappe: What You Actually Get (and What You Don’t) on the SEO Front
Why NextJS with Frappe is failing here
Next.js Inside Frappe: What You Actually Get (and What You Don’t) on the SEO Front
If you’ve spent time building in Frappe/ERPNext, you know the default frontend story: Jinja templates, some jQuery, maybe a Vue component here and there. It works, but the moment you want a rich, modern UI, things get complicated. And if you want SEO? React out of the box won’t help you — it ships an empty <div id="root"> to the browser, and crawlers see nothing useful.
That’s the problem this integration sets out to solve. Let’s go through exactly what it does, what the output looks like, and — critically — what kind of “server-side rendering” you’re actually getting.
How the Integration Works
Running setup-nextjs-frappe.sh from your Frappe app root does a surprising amount of heavy lifting in one shot:
1. Creates a Next.js app inside a frontend/ subdirectory of your Frappe custom app, wired up with TypeScript, ESLint, and the App Router.
2. Configures it for static export (output: "export" in next.config.ts). This is the key architectural decision — Next.js builds everything into flat HTML, CSS, and JS files instead of running a Node server.
3. Sets the correct basePath and assetPrefix so the static files are served by Frappe under /assets/{app_name}/frontend/. In dev mode these are empty, so the Next.js dev server works normally at localhost:3000.
4. Generates a postbuild.mjs script that does two important things after the build:
- Walks every HTML file in the
out/directory and injects a<script>block before</body>that includes Frappe's CSRF token andfrappe_bootdata as Jinja template variables. - Generates Frappe
www/Python files for each route, so every page becomes a proper Frappe page served at a real URL.
5. Creates Python files — page_context.py for providing boot context, and build.py with an after_build hook so bench build --app your_app triggers npm run build automatically.
6. Patches hooks.py to register the after_build hook if it isn't already there.
The project structure after setup looks like this:
apps/{app_name}/
├── {app_name}/
│ ├── hooks.py ← after_build hook added
│ ├── build.py ← triggers npm run build
│ ├── page_context.py ← provides CSRF token + boot context
│ └── www/
│ ├── frontend.py ← root www page entry
│ └── frontend/ ← per-route www pages (generated on build)
└── frontend/ ← Next.js app
├── app/
│ ├── page.tsx
│ └── globals.css
├── lib/
│ └── public-url.ts ← asset path helper
├── scripts/
│ └── postbuild.mjs ← boot injection + www file generator
└── next.config.ts ← static export config
What Kind of “Server-Side Rendering” Are You Getting?
This is the important question, and the honest answer is: Static Site Generation (SSG), not true SSR.
Here’s the distinction:
Approach What happens at request time SEO Plain React (CRA) Browser downloads JS, runs it, renders HTML ❌ Crawlers see empty div Next.js SSR Node server renders HTML per request ✅ Full HTML served Next.js SSG (this integration) HTML is pre-built at build time, served as static files ✅ Full HTML served Next.js ISR HTML rebuilt in the background periodically ✅ Full HTML served
Because output: "export" is set in next.config.ts, Next.js runs a full build that pre-renders every page into a static .html file. The out/ directory then gets copied into Frappe's public/ folder. When a user (or a Google crawler) hits a route, Frappe serves that pre-built HTML file. The crawler sees complete, meaningful HTML — not a blank page waiting for JavaScript.
So yes, compared to a plain React integration, this is a massive SEO improvement. The full page content is in the HTML on first load.
Where This Differs From True Next.js SSR
There is a real trade-off to understand here. In a standard Next.js deployment (on Vercel, or running next start yourself), you can use:
getServerSideProps/ async Server Components — data fetched at request time, so every visitor gets fresh data- Middleware — request-level logic (auth checks, redirects, A/B tests) before the page loads
- Streaming — HTML starts sending before the full page is ready
With output: "export", none of these are available. The Next.js docs are explicit: static export removes any features that require a server. That means:
- No
getServerSidePropsor Server Components that fetch data - No API routes (the
app/api/directory won't work) - No dynamic routes unless you provide
generateStaticParamsso Next.js knows every possible path at build time - No Middleware
What you do get at runtime is the Frappe boot context — CSRF token, user session info, site config — injected into the HTML via Jinja template variables by Frappe’s own server. That’s handled by the postbuild.mjs script which wraps the HTML with {{ frappe.session.csrf_token }} and {{ boot | tojson }} before it's served. So your Next.js code can access window.frappe_boot and window.csrf_token on the client side. But this isn't the same as data being fetched and rendered into the HTML at request time.
What This Means Practically
Good fit for:
- Marketing/landing pages inside a Frappe app — fully static, crawlable, fast
- Dashboards or internal tools where the data loads after the page (via client-side fetches to Frappe’s REST or whitelisted methods)
- Any UI that doesn’t need to render different HTML per-user at the server level
- Apps where you want a modern React/TypeScript DX but still need Frappe’s auth and boot context
Not a good fit for:
- Pages where SEO content varies per request (e.g. a product page that needs to show a price fetched from the DB in the HTML)
- Truly dynamic routes you don’t know at build time
- Any feature depending on Next.js Middleware or API routes
If you need fresh-per-request HTML with real SSR, you’d need to run a Next.js Node server alongside Frappe and proxy routes to it — a significantly more complex setup.
What You Can Improve
A few things worth considering as the integration evolves:
1. No incremental builds. Right now, every bench build triggers a full Next.js build. For large apps this gets slow. A smarter after_build hook could check for file-level changes and skip the build if nothing in frontend/ changed.
2. Dynamic routes need manual generateStaticParams. If you have routes like /frontend/item/[id], you need to add generateStaticParams yourself to tell Next.js which IDs to pre-render. This isn't documented in the README and is a common gotcha.
3. The Frappe boot context arrives late. Because window.frappe_boot is injected by Jinja at serve time, it's available immediately in the HTML — but React's hydration still happens client-side. If any component tries to read window.frappe_boot during server-side rendering (even SSG), it will be undefined. The pattern for using this data safely is to read it inside a useEffect or wrap it in a typeof window !== 'undefined' guard.
4. No ISR equivalent. If you need pages to refresh without a full bench build, you're currently stuck. One approach is to build a small webhook or cron that triggers a rebuild on content change — but that's infrastructure you'd need to add yourself.
5. Image optimization is disabled. images: { unoptimized: true } is set in next.config.ts because Next.js image optimization requires a running server. For production, you'd want to handle image resizing at the Frappe layer or use an external CDN.
The Bottom Line
Compared to integrating plain React into Frappe, this setup is a real step forward. Pre-rendered HTML means crawlers and users both get meaningful content on first load — that’s the core SEO win. The dev experience is also genuinely good: you get full TypeScript, App Router, hot reload at localhost:3000, and automatic wiring into Frappe's build pipeline.
What it isn’t is true SSR. It’s static export, which means page content is fixed at build time. For most use cases inside a Frappe custom app — where you’re building interfaces on top of Frappe’s data layer, not publishing public-facing pages with per-request dynamic content — that’s a perfectly reasonable trade-off. The complexity of running a separate Node server alongside Frappe is rarely worth it for internal tools.
Understand the boundary, work within it, and this integration is a clean and well-thought-out bridge between two ecosystems that weren’t originally designed to talk to each other.

메타데이터
- post_id
- 2f378ee773c1
- slug
- why-nextjs-with-frappe-is-failing-here-2f378ee773c1
- url
- https://medium.com/@devlprnitish/why-nextjs-with-frappe-is-failing-here-2f378ee773c1
- canonical_url
- https://medium.com/@devlprnitish/why-nextjs-with-frappe-is-failing-here-2f378ee773c1
- author_url
- https://medium.com/@devlprnitish
- status
- ok
- fetched_at
- 2026-07-10 08:43:10