Understanding how Next.js static export handles private pages and the security implications for protecting sensitive content.
The Architecture
When using output: 'export' in Next.js, the build produces:
out/
├── _next/
│ └── static/
│ └── chunks/ # ALL JS/CSS for entire site (public)
├── private/
│ ├── me/
│ │ ├── index.html # Private page content
│ │ └── index.txt # RSC payload
│ └── ...
├── writings/
└── index.htmlKey insight: There is only ONE _next/static/ directory at the root. All JavaScript chunks for the entire site (including private pages) are placed here.
What Gets Protected
With edge middleware protecting /private/*:
| Asset | Location | Protected |
|---|---|---|
| Private HTML | /private/**/*.html | Yes (302 redirect) |
| Private RSC payloads | /private/**/*.txt | Yes (302 redirect) |
| Shared JS chunks | /_next/static/chunks/*.js | No (publicly accessible) |
The Soft Leak
Private page component code ends up in public JS chunks:
// Publicly accessible at /_next/static/chunks/abc123.js
{href:"/private/me"}
"← Back to Dashboard"
{value:"notes"}What leaks:
- Route paths (
/private/me,/private/notes) - UI labels ("Dashboard", "Notes", "Drafts")
- Component structure and logic
What does NOT leak:
- Actual content (KB articles, notes, draft posts)
- User data
- API responses
Why This Happens
Next.js chunks are split by module graph, not by route protection:
- Shared components (layout, nav) reference private routes
- Private page components get bundled into shared chunks
- All chunks go to global
/_next/static/ - No per-route output directory support
Can We Fix It?
What Doesn't Work
| Approach | Why It Fails |
|---|---|
assetPrefix | Global setting, not per-route |
basePath | Changes entire app, not specific routes |
Webpack splitChunks | No per-entry output path support |
| Custom output directory | Not supported by Next.js |
Workarounds
1. Accept the Soft Leak (Simplest)
For personal sites or internal tools, the component code leak may be acceptable since actual content remains protected.
2. Client-Side Only Private Pages
Don't statically generate private pages. Fetch content after auth:
// app/private/me/page.tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export default function PrivateDashboard() {
const { data, isLoading } = useQuery({
queryKey: ['dashboard'],
queryFn: () => fetch('/api/dashboard').then(r => r.json()),
});
if (isLoading) return <Skeleton />;
return <Dashboard data={data} />;
}Benefits:
- No private components in static chunks
- Content fetched from API after middleware auth
- Middleware still protects the route shell
Trade-offs:
- No SSG benefits for private pages
- Requires API endpoints for private data
- Loading states required
3. Separate Deployment
Split into two Next.js apps:
- Public app at
kai.land - Private app at
private.kai.land(with its own/_next/static/)
Trade-offs:
- Two builds, two deployments
- Shared components must be duplicated or packaged
4. Post-Build Script
Move private-specific chunks after build:
# Identify chunks only used by private pages
# Move them to /private/_next/static/
# Update HTML referencesTrade-offs:
- Fragile (chunk filenames change each build)
- Must track which chunks are private-only
- Complex to maintain
Verification Commands
Check if directory listing is enabled:
curl -s -o /dev/null -w "%{http_code}" "https://example.com/_next/static/"
# Should return 404 (no listing)Check if private routes are protected:
curl -s -o /dev/null -w "%{http_code}" "https://example.com/private/me"
# Should return 302 (redirect to login)Search for private UI strings in public chunks:
find out/_next -name "*.js" | xargs grep -l "Dashboard\|Private Notes"Check what JS a private page references:
grep -o '/_next/static/chunks/[^"]*\.js' out/private/me/index.html | sort -uRecommendation
For most use cases with edge middleware on Cloudflare Pages:
- Content is protected - actual sensitive data never leaks
- Soft leak is acceptable - UI structure visible but not harmful
- If strict security needed - use client-side rendering for private pages
The middleware approach protects what matters (content), even if it can't hide that private routes exist.
Related
- cloudflare-workers-env-vars - Environment variables in Cloudflare Workers
- Next.js Static Exports Guide
- Next.js Discussion: Protecting static JS chunks