2026 04 01 Www Full Page Cache
www Full Page Cache Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Convert apps/www from Next.js SSR/ISR to fully static export (output: 'export') so the site can be served from Nginx/CDN with no running server.
Architecture: Switch next.config.mjs to output: 'export', delete all API routes and middleware (unsupported in static export), remove ISR/dynamic directives from all pages, replace runtime API fetches in BlogClient and BlogFilters with client-side operations over props passed at build time, and serve the resulting out/ directory from Nginx.
Tech Stack: Next.js 15 static export, Nginx Alpine, Podman Compose, pnpm workspaces
File Map#
Task 1: Switch next.config.mjs to static export#
Files:
-
Modify:
apps/www/next.config.mjs -
Step 1: Update
next.config.mjsOpen
apps/www/next.config.mjs. In thenextConfigobject:- Replace
output: 'standalone'withoutput: 'export' - Remove the two cache handler lines:
js1// REMOVE these two lines:2cacheHandler: require.resolve('config/next-cache-handler.mjs'),3cacheMaxMemorySize: 0,
- In the
imagesblock, addunoptimized: true:js1images: {2dangerouslyAllowSVG: false,3unoptimized: true, // ← add this4remotePatterns,5qualities: [75, 85, 100],6},
- Replace
-
Step 2: Verify the change compiles
bash1cd apps/www && node -e "import('./next.config.mjs').then(m => console.log('ok'))" 2>&1 | head -5Expected:
ok(no syntax error) -
Step 3: Commit
bash1git add apps/www/next.config.mjs2git commit -m "feat(www): switch to output: export for static site generation"
Task 2: Delete unsupported files#
middleware.ts is not supported with output: 'export' and will cause a build error. The /api-v2/ routes are server-only and also incompatible.
Files:
-
Delete:
apps/www/middleware.ts -
Delete:
apps/www/app/api-v2/(entire directory) -
Step 1: Delete middleware
bash1rm apps/www/middleware.ts -
Step 2: Delete all API routes
bash1rm -rf apps/www/app/api-v2 -
Step 3: Commit
bash1git add -A apps/www/middleware.ts apps/www/app/api-v22git commit -m "feat(www): remove middleware and api-v2 routes (unsupported in static export)"
Task 3: Remove force-dynamic from pages layout and simple pages#
(pages)/layout.tsx has export const dynamic = 'force-dynamic' which cascades to all child routes, preventing static generation. The assistant page and features/[slug] page also have it.
Files:
-
Modify:
apps/www/app/(pages)/layout.tsx -
Modify:
apps/www/app/(pages)/assistant/page.tsx -
Modify:
apps/www/app/(pages)/features/[slug]/page.tsx -
Step 1: Fix
(pages)/layout.tsxCurrent content of
apps/www/app/(pages)/layout.tsx:tsx1export const dynamic = 'force-dynamic'23export default function PagesLayout({ children }: { children: React.ReactNode }) {4return children5}Replace with:
tsx1export default function PagesLayout({ children }: { children: React.ReactNode }) {2return children3} -
Step 2: Fix
assistant/page.tsxRemove
export const dynamic = 'force-dynamic'fromapps/www/app/(pages)/assistant/page.tsx.Current:
tsx1import type { Metadata } from 'next'2import PageComponent from './AssistantPage'34export const dynamic = 'force-dynamic'56export const metadata: Metadata = { ... }78export default function Page() {9return <PageComponent />10}Remove only the
export const dynamic = 'force-dynamic'line. -
Step 3: Fix
features/[slug]/page.tsxCurrent content:
tsx1import FeatureSlugClient from './FeatureSlugClient'23export const dynamic = 'force-dynamic'45export default function Page() {6return <FeatureSlugClient />7}Replace with (adds
generateStaticParamsanddynamicParams = false; the data comes from~/data/features):tsx1import { features } from '~/data/features'2import FeatureSlugClient from './FeatureSlugClient'34export const dynamicParams = false56export async function generateStaticParams() {7return features.map((feature) => ({ slug: feature.slug }))8}910export default function Page() {11return <FeatureSlugClient />12} -
Step 4: Commit
bash1git add apps/www/app/\(pages\)/layout.tsx \2apps/www/app/\(pages\)/assistant/page.tsx \3apps/www/app/\(pages\)/features/\[slug\]/page.tsx4git commit -m "feat(www): remove force-dynamic from layout and pages, add generateStaticParams to features/[slug]"
Task 4: Fix changelog — build-time fetch, remove pagination#
The changelog page has force-dynamic and revalidate = 900. It also has cursor-based pagination (?next= query param) which is impossible in a static export. Fix: render the first page of entries at build time; drop pagination.
Files:
-
Modify:
apps/www/app/(pages)/changelog/page.tsx -
Step 1: Remove
force-dynamic,revalidate, and paginationIn
apps/www/app/(pages)/changelog/page.tsx:-
Remove these two lines near the top:
ts1export const = 'force-dynamic'2export const = 900 // 15 minutes -
Change the Page component signature — remove the
searchParamsprop entirely (no pagination):tsx1// BEFORE:2export default async function Page({3searchParams,4}: {5searchParams: Promise<{ next?: string; restPage?: string }>6}) {7const params = await searchParams8const next = recursiveDecodeURI((params.next ?? null) as string | null)9const restPage = params.restPage ? Number(params.restPage) : 11011// AFTER:12export default async function Page() {13const next = null14const restPage = 1 -
Remove the
recursiveDecodeURIhelper function (no longer needed). -
The rest of the function body (
fetchGitHubReleases,fetchDiscussions, etc.) stays unchanged — it already builds at build time whenforce-dynamicis removed. -
In the return, pass
restPage={1}hardcoded — pagination links will be gone since the component will receive page 1 always. IfChangelogPagehas "load more" or pagination UI, checkChangelogPage.tsxand remove those controls (they require server re-render to work). Replace with a link:tsx1// If ChangelogPage renders pagination controls, replace them with:2<a href="https://github.com/assistance/assistance/releases" target="_blank" rel="noopener">3View all releases on GitHub →4</a>(Only do this if ChangelogPage actually renders pagination; if not, no change needed.)
-
-
Step 2: Check ChangelogPage for pagination UI
bash1grep -n "pageInfo\|hasNextPage\|next=\|restPage\|loadMore" apps/www/app/\(pages\)/changelog/ChangelogPage.tsx 2>/dev/null || echo "no pagination"If output is not "no pagination", find those UI elements in
ChangelogPage.tsxand remove the forward/back navigation buttons. Thechangelogprop (the list of entries) remains untouched. -
Step 3: Commit
bash1git add apps/www/app/\(pages\)/changelog/2git commit -m "feat(www): changelog fetches at build time, removes cursor pagination"
Task 5: Remove revalidate and draftMode from blog routes#
Files:
-
Modify:
apps/www/app/blog/[slug]/page.tsx -
Modify:
apps/www/app/blog/page.tsx -
Modify:
apps/www/app/blog/categories/[category]/page.tsx -
Modify:
apps/www/app/blog/tags/[tag]/page.tsx -
Modify:
apps/www/app/blog/authors/[author]/page.tsx -
Modify:
apps/www/app/(pages)/careers/page.tsx -
Step 1: Fix
blog/[slug]/page.tsx-
Remove
export const revalidate = 86400 -
Remove the
draftModeimport:import { draftMode } from 'next/headers' -
Replace both calls to
draftMode()with{ isEnabled: false }:In
generateMetadata:tsx1// REMOVE:2const { isEnabled: isDraft } = await draftMode()3// ADD (or just remove the line — isDraft is unused in generateMetadata):Check if
isDraftis used ingenerateMetadata. If not, just delete that line.In the default export:
tsx1// REMOVE:2const { isEnabled: isDraft } = await draftMode()3// ADD:4const isDraft = false
-
-
Step 2: Fix
blog/page.tsxRemove
export const revalidate = 3600.Also change the blog page to pass ALL posts instead of just the first 25 (the BlogClient will handle client-side pagination):
tsx1// REMOVE:2const INITIAL_POSTS_LIMIT = 253// ...4const initialPosts = allPosts.slice(0, INITIAL_POSTS_LIMIT)5const totalPosts = allPosts.length6return <BlogClient initialBlogs={initialPosts} totalPosts={totalPosts} />78// REPLACE WITH:9return <BlogClient allPosts={allPosts} />Note:
BlogClientprops will change in Task 7. For now, just make this change and expect a TypeScript error that Task 7 will fix. -
Step 3: Fix blog filter/listing pages
In
apps/www/app/blog/categories/[category]/page.tsx:- Remove
export const revalidate = 30 - Add
export const dynamicParams = false(after existingexport async function generateStaticParams)
In
apps/www/app/blog/tags/[tag]/page.tsx:- Remove
export const revalidate = 30 - Add
export const dynamicParams = false
In
apps/www/app/blog/authors/[author]/page.tsx:- Remove
export const revalidate = 30 - Add
export const dynamicParams = false
- Remove
-
Step 4: Fix
careers/page.tsxRemove
export const revalidate = 300.The
fetch()calls (Ashby API, GitHub contributors) remain — they run at build time now. -
Step 5: Commit
bash1git add apps/www/app/blog/ apps/www/app/\(pages\)/careers/2git commit -m "feat(www): remove ISR revalidate and draftMode from blog and careers pages"
Task 6: Add dynamicParams = false to remaining dynamic routes#
Without dynamicParams = false, Next.js static export will try to render unknown slugs at request time — which static export doesn't support. All dynamic routes need this guard.
Files:
-
Modify:
apps/www/app/case-studies/[slug]/page.tsx -
Modify:
apps/www/app/(pages)/alternatives/[slug]/page.tsx -
Modify:
apps/www/app/(pages)/events/[slug]/page.tsx -
Modify:
apps/www/app/(pages)/services/[slug]/page.tsx -
Modify:
apps/www/app/runners/[slug]/page.tsx -
Modify:
apps/www/app/trainings/[platform]/page.tsx -
Modify:
apps/www/app/trainings/[platform]/[slug]/page.tsx -
Step 1: Add
dynamicParams = falseto each fileIn each file listed above, add this line after the
generateStaticParamsexport:tsx1export const dynamicParams = falseExact placement for each:
case-studies/[slug]/page.tsx— add after line 13:tsx1export async function generateStaticParams() {2const paths = getAllPostSlugs('_case-studies')3return paths.map((p) => ({ slug: p.params.slug }))4}56export const dynamicParams = false // ← add here(pages)/alternatives/[slug]/page.tsx— add after thegenerateStaticParamsfunction.(pages)/events/[slug]/page.tsx— add after thegenerateStaticParamsfunction.(pages)/services/[slug]/page.tsx— add after thegenerateStaticParamsfunction.runners/[slug]/page.tsx— add after thegenerateStaticParamsfunction.trainings/[platform]/page.tsx— add after thegenerateStaticParamsfunction.trainings/[platform]/[slug]/page.tsx— add after thegenerateStaticParamsfunction. -
Step 2: Commit
bash1git add apps/www/app/case-studies/ \2apps/www/app/\(pages\)/alternatives/ \3apps/www/app/\(pages\)/events/ \4apps/www/app/\(pages\)/services/ \5apps/www/app/runners/ \6apps/www/app/trainings/7git commit -m "feat(www): add dynamicParams = false to all remaining dynamic routes"
Task 7: Fix blog listing — client-side filtering without API#
BlogClient.tsx fetches /api-v2/blog-posts for infinite scroll and filtering. BlogFilters.tsx fetches /api-v2/blog-categories. Both APIs are deleted. Replace with client-side operations over all posts passed as props at build time.
Files:
-
Modify:
apps/www/app/blog/BlogClient.tsx -
Modify:
apps/www/components/Blog/BlogFilters.tsx -
Step 1: Update
BlogFilters.tsxto acceptcategoriespropChange the
Propsinterface and remove theuseEffectthat fetches categories:tsx1// BEFORE:2interface Props {3onFilterChange: (category?: string, search?: string) => void4}56function BlogFilters({ onFilterChange }: Props) {7const [allCategories, setAllCategories] = useState<string[]>(['all'])8// ...9useEffect(() => {10async function fetchCategories() {11try {12const response = await fetch('/api-v2/blog-categories')13// ...14setAllCategories(['all', ...data.categories])15} catch (error) { ... }16}17fetchCategories()18}, [])tsx1// AFTER:2interface Props {3categories: string[]4onFilterChange: (category?: string, search?: string) => void5}67function BlogFilters({ categories, onFilterChange }: Props) {8const [allCategories] = useState<string[]>(['all', ...categories])9// Remove the useEffect that fetches categories entirelyThe rest of the component body (search, category handling, JSX) stays unchanged.
-
Step 2: Update
BlogClient.tsx— new props and client-side filteringReplace the
BlogClientPropsinterface and the component signature:tsx1// BEFORE:2interface BlogClientProps {3initialBlogs: any[]4totalPosts: number5}67export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps) {tsx1// AFTER:2interface BlogClientProps {3allPosts: any[]4}56// Note: POSTS_PER_PAGE = 25 is already defined in the file — do not re-declare it.78export default function BlogClient({ allPosts }: BlogClientProps) { -
Step 3: Replace API-based filtering with client-side filtering in
BlogClient.tsxReplace the state and callbacks that call
fetch():tsx1// REMOVE the entire fetchMorePosts useCallback and its fetch call.2// REMOVE the handleFilterChange useCallback and its fetch call.34// REPLACE WITH:5const [filterParams, setFilterParams] = useState<{ category?: string; search?: string }>({})67const filteredPosts = useMemo(() => {8let posts = allPosts9if (filterParams.category && filterParams.category !== 'all') {10posts = posts.filter((p: any) => p.categories?.includes(filterParams.category))11}12if (filterParams.search) {13const q = filterParams.search.toLowerCase()14posts = posts.filter(15(p: any) =>16p.title?.toLowerCase().includes(q) ||17p.author?.toLowerCase().includes(q) ||18p.tags?.join(' ').toLowerCase().includes(q) ||19p.categories?.join(' ').toLowerCase().includes(q)20)21}22return posts23}, [allPosts, filterParams])2425const fetchMorePosts = useCallback(26async (offset: number, limit: number) => {27return filteredPosts.slice(offset, offset + limit)28},29[filteredPosts]30)3132const handleFilterChange = useCallback((category?: string, search?: string) => {33setFilterParams({ category, search })34}, [])Also add
useMemoto the imports at the top:tsx1import { useState, useCallback, useMemo } from 'react'Remove the
isFilteringstate and its usage (no longer needed — filtering is synchronous):- Remove
const [isFiltering, setIsFiltering] = useState(false) - Remove
const [filteredPosts, setFilteredPosts] = useState<any[] | null>(null) - Remove
const [filteredTotal, setFilteredTotal] = useState<number | null>(null) - Remove
const currentPosts = filteredPosts ?? initialBlogs(replace withfilteredPostsfrom useMemo) - Remove
const currentTotal = filteredTotal ?? totalPosts(replace withfilteredPosts.length)
Update the
useInfiniteScrollWithFetchcall:tsx1const {2items: blogs,3hasMore,4loadMoreRef,5} = useInfiniteScrollWithFetch({6initialItems: filteredPosts.slice(0, POSTS_PER_PAGE),7totalItems: filteredPosts.length,8pageSize: POSTS_PER_PAGE,9fetchMore: fetchMorePosts,10rootMargin: '1000px',11})The skeleton loader in JSX checks
isFiltering— remove that branch since filtering is now instant:tsx1// REMOVE:2{isFiltering3? Array.from({ length: SKELETON_COUNT }).map(...)4: blogs?.length ...}56// REPLACE WITH:7{blogs?.length8? blogs.map((blog: PostTypes, idx: number) => ...)9: <p className="text-sm text-light col-span-full">No results</p>10}Remove
SKELETON_COUNTconstant andBlogListItemSkeletoncomponent (no longer used). - Remove
-
Step 4: Update
BlogFiltersusage inBlogClient.tsxDerive categories from
allPostsand pass them toBlogFilters:tsx1const allCategories = useMemo(() => {2const cats = new Set<string>()3for (const post of allPosts) {4for (const cat of (post.categories ?? [])) {5cats.add(cat)6}7}8return Array.from(cats).sort((a, b) => a.localeCompare(b))9}, [allPosts])Then in JSX:
tsx1// BEFORE:2<BlogFilters onFilterChange={handleFilterChange} />34// AFTER:5<BlogFilters categories={allCategories} onFilterChange={handleFilterChange} /> -
Step 5: Run TypeScript check
bash1cd apps/www && pnpm exec tsc --noEmit 2>&1 | head -40Fix any TypeScript errors (likely just prop type mismatches from the interface changes).
-
Step 6: Commit
bash1git add apps/www/app/blog/BlogClient.tsx \2apps/www/components/Blog/BlogFilters.tsx \3apps/www/app/blog/page.tsx4git commit -m "feat(www): replace API-based blog filtering with client-side filtering over all posts"
Task 8: Replace opt-out/[ref] with static fallback page#
The opt-out/[ref] page submits to /api-v2/opt-out/... which is now deleted. The [ref] dynamic segment can't be statically enumerated. Replace with a simple static page.
Files:
-
Delete:
apps/www/app/(pages)/opt-out/[ref]/page.tsx -
Modify:
apps/www/app/(pages)/opt-out/page.tsx -
Step 1: Delete the dynamic route
bash1rm apps/www/app/\(pages\)/opt-out/\[ref\]/page.tsx2rmdir apps/www/app/\(pages\)/opt-out/\[ref\] 2>/dev/null || true -
Step 2: Check if
opt-out/page.tsxexistsbash1ls apps/www/app/\(pages\)/opt-out/If
page.tsxalready exists, read it first. If it doesn't exist, create it. -
Step 3: Write static fallback page
Create/replace
apps/www/app/(pages)/opt-out/page.tsx:tsx1import type { Metadata } from 'next'2import DefaultLayout from '~/components/Layouts/Default'34export const metadata: Metadata = {5title: 'Opt Out | Assistance',6description: 'Opt out of receiving emails from Assistance.',7}89export default function OptOutPage() {10return (11<DefaultLayout>12<div className="mx-auto max-w-xl mt-24 px-8 flex flex-col gap-6">13<div>14<h1 className="text-2xl font-bold">Opt out</h1>15<p className="text-foreground-light mt-2">16To unsubscribe from Assistance emails or report an unexpected message, please contact us at{' '}17<a href="mailto:team@assistance.bg" className="text-brand-600 underline">18team@assistance.bg19</a>20.21</p>22</div>23</div>24</DefaultLayout>25)26} -
Step 4: Commit
bash1git add -A apps/www/app/\(pages\)/opt-out/2git commit -m "feat(www): replace opt-out/[ref] dynamic route with static contact page"
Task 9: Build verification#
Run the full static build and fix any remaining errors.
Files: varies (fix whatever the build reports)
-
Step 1: Run the build
bash1cd apps/www && pnpm build 2>&1 | tee /tmp/www-build.logExpected on success:
✓ Generating static pages (N/N)followed by✓ Finalizing page optimizationand exit code 0. -
Step 2: Fix
next/headersuses if anyIf the build fails with errors like
"next/headers" is not supported in static exports, find and fix them:bash1grep -rn "from 'next/headers'" apps/www/app/ apps/www/components/For each hit:
draftMode()→ remove the call, hardcodeisDraft = falsecookies()→ remove the call, use a default valueheaders()→ remove the call, use a default value
-
Step 3: Fix any remaining
force-dynamicusesbash1grep -rn "force-dynamic" apps/www/app/Remove any remaining
export const dynamic = 'force-dynamic'lines. -
Step 4: Fix any remaining
revalidateexportsbash1grep -rn "^export const revalidate" apps/www/app/Remove any remaining
export const revalidate = ...lines. -
Step 5: Fix TypeScript errors
If the build fails with TypeScript errors, run:
bash1cd apps/www && pnpm exec tsc --noEmit 2>&1 | head -60and fix each error.
-
Step 6: Verify the output directory
bash1ls apps/www/out/ | head -202ls apps/www/out/_next/static/ | head -5Expected: HTML files and
_next/static/directory. -
Step 7: Commit
bash1git add -A apps/www/2git commit -m "feat(www): fix remaining static export build issues"
Task 10: Nginx config#
Create the Nginx config that serves out/ as a static site with the /contact-sales redirect that was previously in middleware.
Files:
-
Create:
tools/nginx/www.conf -
Step 1: Create the directory
bash1mkdir -p tools/nginx -
Step 2: Create
tools/nginx/www.confnginx1server {2listen 3000;3server_name _;45root /srv/www/out;6index index.html;78# Redirect moved from Next.js middleware9location = /contact-sales {10return 308 /contact-us;11}1213# Static assets are content-hashed — cache forever14location /_next/static/ {15add_header Cache-Control "public, max-age=31536000, immutable";16try_files $uri =404;17}1819# Public directory assets (images, fonts, etc.)20location /images/ {21add_header Cache-Control "public, max-age=86400";22try_files $uri =404;23}2425location /fonts/ {26add_header Cache-Control "public, max-age=31536000, immutable";27try_files $uri =404;28}2930# HTML pages — short cache, Cloudflare CDN-Cache-Control handles edge TTL31location / {32add_header Cache-Control "public, max-age=3600";33try_files $uri $uri.html $uri/index.html =404;34error_page 404 /404.html;35}36} -
Step 3: Commit
bash1git add tools/nginx/www.conf2git commit -m "feat(www): add nginx static serving config for static export"
Task 11: Update Podman Compose#
Change the www service in podman-compose.yml from a Node standalone container to an Nginx static container.
Files:
-
Modify:
podman-compose.yml -
Step 1: Read the current
wwwservice definitionbash1grep -A 20 "^\s*www:" podman-compose.ymlNote the current image, ports, and environment variables.
-
Step 2: Replace the
wwwserviceFind and replace the
www:service block. The new service mountsapps/www/outand the Nginx config:yaml1www:2image: nginx:alpine3ports:4- "3050:3000"5volumes:6- ./apps/www/out:/srv/www/out:ro7- ./tools/nginx/www.conf:/etc/nginx/conf.d/default.conf:ro8restart: unless-stoppedRemove any
environment:,build:,command:, or Node-specific options from the oldwwwblock.If the old service has health checks that ping the Node server, remove those too.
-
Step 3: Verify compose file parses
bash1podman-compose -f podman-compose.yml config 2>&1 | head -10Expected: no errors (config is printed or exits 0).
-
Step 4: Commit
bash1git add podman-compose.yml2git commit -m "feat(www): replace Node standalone container with Nginx static serving"
Self-review checklist#
After all tasks are done, verify:
-
pnpm --filter www buildsucceeds with exit code 0 -
apps/www/out/contains HTML files includingindex.html,blog.html,pricing.html -
apps/www/out/_next/static/contains JS/CSS chunks -
grep -r "force-dynamic" apps/www/app/returns nothing -
grep -r "^export const revalidate" apps/www/app/returns nothing -
grep -r "from 'next/headers'" apps/www/app/returns nothing -
grep -r "api-v2" apps/www/app/ apps/www/components/returns nothing -
ls apps/www/middleware.tsreturns "no such file"