Services

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#

ActionFile
Modifyapps/www/next.config.mjs
Deleteapps/www/middleware.ts
Deleteapps/www/app/api-v2/ (entire directory)
Modifyapps/www/app/(pages)/layout.tsx
Modifyapps/www/app/(pages)/changelog/page.tsx
Modifyapps/www/app/(pages)/features/[slug]/page.tsx
Modifyapps/www/app/(pages)/assistant/page.tsx
Modifyapps/www/app/blog/[slug]/page.tsx
Modifyapps/www/app/blog/page.tsx
Modifyapps/www/app/blog/categories/[category]/page.tsx
Modifyapps/www/app/blog/tags/[tag]/page.tsx
Modifyapps/www/app/blog/authors/[author]/page.tsx
Modifyapps/www/app/(pages)/careers/page.tsx
Modifyapps/www/app/case-studies/[slug]/page.tsx
Modifyapps/www/app/(pages)/alternatives/[slug]/page.tsx
Modifyapps/www/app/(pages)/events/[slug]/page.tsx
Modifyapps/www/app/(pages)/services/[slug]/page.tsx
Modifyapps/www/app/runners/[slug]/page.tsx
Modifyapps/www/app/trainings/[platform]/page.tsx
Modifyapps/www/app/trainings/[platform]/[slug]/page.tsx
Deleteapps/www/app/(pages)/opt-out/[ref]/page.tsx
Modifyapps/www/app/(pages)/opt-out/page.tsx (static fallback)
Modifyapps/www/components/Blog/BlogFilters.tsx
Modifyapps/www/app/blog/BlogClient.tsx
Createtools/nginx/www.conf
Modifypodman-compose.yml

Task 1: Switch next.config.mjs to static export#

Files:

  • Modify: apps/www/next.config.mjs

  • Step 1: Update next.config.mjs

    Open apps/www/next.config.mjs. In the nextConfig object:

    1. Replace output: 'standalone' with output: 'export'
    2. Remove the two cache handler lines:
      js
      1
      // REMOVE these two lines:
      2
      cacheHandler: require.resolve('config/next-cache-handler.mjs'),
      3
      cacheMaxMemorySize: 0,
    3. In the images block, add unoptimized: true:
      js
      1
      images: {
      2
      dangerouslyAllowSVG: false,
      3
      unoptimized: true, // ← add this
      4
      remotePatterns,
      5
      qualities: [75, 85, 100],
      6
      },
  • Step 2: Verify the change compiles

    bash
    1
    cd apps/www && node -e "import('./next.config.mjs').then(m => console.log('ok'))" 2>&1 | head -5

    Expected: ok (no syntax error)

  • Step 3: Commit

    bash
    1
    git add apps/www/next.config.mjs
    2
    git 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

    bash
    1
    rm apps/www/middleware.ts
  • Step 2: Delete all API routes

    bash
    1
    rm -rf apps/www/app/api-v2
  • Step 3: Commit

    bash
    1
    git add -A apps/www/middleware.ts apps/www/app/api-v2
    2
    git 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.tsx

    Current content of apps/www/app/(pages)/layout.tsx:

    tsx
    1
    export const dynamic = 'force-dynamic'
    2
    3
    export default function PagesLayout({ children }: { children: React.ReactNode }) {
    4
    return children
    5
    }

    Replace with:

    tsx
    1
    export default function PagesLayout({ children }: { children: React.ReactNode }) {
    2
    return children
    3
    }
  • Step 2: Fix assistant/page.tsx

    Remove export const dynamic = 'force-dynamic' from apps/www/app/(pages)/assistant/page.tsx.

    Current:

    tsx
    1
    import type { Metadata } from 'next'
    2
    import PageComponent from './AssistantPage'
    3
    4
    export const dynamic = 'force-dynamic'
    5
    6
    export const metadata: Metadata = { ... }
    7
    8
    export default function Page() {
    9
    return <PageComponent />
    10
    }

    Remove only the export const dynamic = 'force-dynamic' line.

  • Step 3: Fix features/[slug]/page.tsx

    Current content:

    tsx
    1
    import FeatureSlugClient from './FeatureSlugClient'
    2
    3
    export const dynamic = 'force-dynamic'
    4
    5
    export default function Page() {
    6
    return <FeatureSlugClient />
    7
    }

    Replace with (adds generateStaticParams and dynamicParams = false; the data comes from ~/data/features):

    tsx
    1
    import { features } from '~/data/features'
    2
    import FeatureSlugClient from './FeatureSlugClient'
    3
    4
    export const dynamicParams = false
    5
    6
    export async function generateStaticParams() {
    7
    return features.map((feature) => ({ slug: feature.slug }))
    8
    }
    9
    10
    export default function Page() {
    11
    return <FeatureSlugClient />
    12
    }
  • Step 4: Commit

    bash
    1
    git add apps/www/app/\(pages\)/layout.tsx \
    2
    apps/www/app/\(pages\)/assistant/page.tsx \
    3
    apps/www/app/\(pages\)/features/\[slug\]/page.tsx
    4
    git 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 pagination

    In apps/www/app/(pages)/changelog/page.tsx:

    1. Remove these two lines near the top:

      ts
      1
      export const = 'force-dynamic'
      2
      export const = 900 // 15 minutes
    2. Change the Page component signature — remove the searchParams prop entirely (no pagination):

      tsx
      1
      // BEFORE:
      2
      export default async function Page({
      3
      searchParams,
      4
      }: {
      5
      searchParams: Promise<{ next?: string; restPage?: string }>
      6
      }) {
      7
      const params = await searchParams
      8
      const next = recursiveDecodeURI((params.next ?? null) as string | null)
      9
      const restPage = params.restPage ? Number(params.restPage) : 1
      10
      11
      // AFTER:
      12
      export default async function Page() {
      13
      const next = null
      14
      const restPage = 1
    3. Remove the recursiveDecodeURI helper function (no longer needed).

    4. The rest of the function body (fetchGitHubReleases, fetchDiscussions, etc.) stays unchanged — it already builds at build time when force-dynamic is removed.

    5. In the return, pass restPage={1} hardcoded — pagination links will be gone since the component will receive page 1 always. If ChangelogPage has "load more" or pagination UI, check ChangelogPage.tsx and remove those controls (they require server re-render to work). Replace with a link:

      tsx
      1
      // If ChangelogPage renders pagination controls, replace them with:
      2
      <a href="https://github.com/assistance/assistance/releases" target="_blank" rel="noopener">
      3
      View 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

    bash
    1
    grep -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.tsx and remove the forward/back navigation buttons. The changelog prop (the list of entries) remains untouched.

  • Step 3: Commit

    bash
    1
    git add apps/www/app/\(pages\)/changelog/
    2
    git 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

    1. Remove export const revalidate = 86400

    2. Remove the draftMode import: import { draftMode } from 'next/headers'

    3. Replace both calls to draftMode() with { isEnabled: false }:

      In generateMetadata:

      tsx
      1
      // REMOVE:
      2
      const { isEnabled: isDraft } = await draftMode()
      3
      // ADD (or just remove the line — isDraft is unused in generateMetadata):

      Check if isDraft is used in generateMetadata. If not, just delete that line.

      In the default export:

      tsx
      1
      // REMOVE:
      2
      const { isEnabled: isDraft } = await draftMode()
      3
      // ADD:
      4
      const isDraft = false
  • Step 2: Fix blog/page.tsx

    Remove 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):

    tsx
    1
    // REMOVE:
    2
    const INITIAL_POSTS_LIMIT = 25
    3
    // ...
    4
    const initialPosts = allPosts.slice(0, INITIAL_POSTS_LIMIT)
    5
    const totalPosts = allPosts.length
    6
    return <BlogClient initialBlogs={initialPosts} totalPosts={totalPosts} />
    7
    8
    // REPLACE WITH:
    9
    return <BlogClient allPosts={allPosts} />

    Note: BlogClient props 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 existing export 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
  • Step 4: Fix careers/page.tsx

    Remove export const revalidate = 300.

    The fetch() calls (Ashby API, GitHub contributors) remain — they run at build time now.

  • Step 5: Commit

    bash
    1
    git add apps/www/app/blog/ apps/www/app/\(pages\)/careers/
    2
    git 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 = false to each file

    In each file listed above, add this line after the generateStaticParams export:

    tsx
    1
    export const dynamicParams = false

    Exact placement for each:

    case-studies/[slug]/page.tsx — add after line 13:

    tsx
    1
    export async function generateStaticParams() {
    2
    const paths = getAllPostSlugs('_case-studies')
    3
    return paths.map((p) => ({ slug: p.params.slug }))
    4
    }
    5
    6
    export const dynamicParams = false // ← add here

    (pages)/alternatives/[slug]/page.tsx — add after the generateStaticParams function.

    (pages)/events/[slug]/page.tsx — add after the generateStaticParams function.

    (pages)/services/[slug]/page.tsx — add after the generateStaticParams function.

    runners/[slug]/page.tsx — add after the generateStaticParams function.

    trainings/[platform]/page.tsx — add after the generateStaticParams function.

    trainings/[platform]/[slug]/page.tsx — add after the generateStaticParams function.

  • Step 2: Commit

    bash
    1
    git add apps/www/app/case-studies/ \
    2
    apps/www/app/\(pages\)/alternatives/ \
    3
    apps/www/app/\(pages\)/events/ \
    4
    apps/www/app/\(pages\)/services/ \
    5
    apps/www/app/runners/ \
    6
    apps/www/app/trainings/
    7
    git 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.tsx to accept categories prop

    Change the Props interface and remove the useEffect that fetches categories:

    tsx
    1
    // BEFORE:
    2
    interface Props {
    3
    onFilterChange: (category?: string, search?: string) => void
    4
    }
    5
    6
    function BlogFilters({ onFilterChange }: Props) {
    7
    const [allCategories, setAllCategories] = useState<string[]>(['all'])
    8
    // ...
    9
    useEffect(() => {
    10
    async function fetchCategories() {
    11
    try {
    12
    const response = await fetch('/api-v2/blog-categories')
    13
    // ...
    14
    setAllCategories(['all', ...data.categories])
    15
    } catch (error) { ... }
    16
    }
    17
    fetchCategories()
    18
    }, [])
    tsx
    1
    // AFTER:
    2
    interface Props {
    3
    categories: string[]
    4
    onFilterChange: (category?: string, search?: string) => void
    5
    }
    6
    7
    function BlogFilters({ categories, onFilterChange }: Props) {
    8
    const [allCategories] = useState<string[]>(['all', ...categories])
    9
    // Remove the useEffect that fetches categories entirely

    The rest of the component body (search, category handling, JSX) stays unchanged.

  • Step 2: Update BlogClient.tsx — new props and client-side filtering

    Replace the BlogClientProps interface and the component signature:

    tsx
    1
    // BEFORE:
    2
    interface BlogClientProps {
    3
    initialBlogs: any[]
    4
    totalPosts: number
    5
    }
    6
    7
    export default function BlogClient({ initialBlogs, totalPosts }: BlogClientProps) {
    tsx
    1
    // AFTER:
    2
    interface BlogClientProps {
    3
    allPosts: any[]
    4
    }
    5
    6
    // Note: POSTS_PER_PAGE = 25 is already defined in the file — do not re-declare it.
    7
    8
    export default function BlogClient({ allPosts }: BlogClientProps) {
  • Step 3: Replace API-based filtering with client-side filtering in BlogClient.tsx

    Replace the state and callbacks that call fetch():

    tsx
    1
    // REMOVE the entire fetchMorePosts useCallback and its fetch call.
    2
    // REMOVE the handleFilterChange useCallback and its fetch call.
    3
    4
    // REPLACE WITH:
    5
    const [filterParams, setFilterParams] = useState<{ category?: string; search?: string }>({})
    6
    7
    const filteredPosts = useMemo(() => {
    8
    let posts = allPosts
    9
    if (filterParams.category && filterParams.category !== 'all') {
    10
    posts = posts.filter((p: any) => p.categories?.includes(filterParams.category))
    11
    }
    12
    if (filterParams.search) {
    13
    const q = filterParams.search.toLowerCase()
    14
    posts = posts.filter(
    15
    (p: any) =>
    16
    p.title?.toLowerCase().includes(q) ||
    17
    p.author?.toLowerCase().includes(q) ||
    18
    p.tags?.join(' ').toLowerCase().includes(q) ||
    19
    p.categories?.join(' ').toLowerCase().includes(q)
    20
    )
    21
    }
    22
    return posts
    23
    }, [allPosts, filterParams])
    24
    25
    const fetchMorePosts = useCallback(
    26
    async (offset: number, limit: number) => {
    27
    return filteredPosts.slice(offset, offset + limit)
    28
    },
    29
    [filteredPosts]
    30
    )
    31
    32
    const handleFilterChange = useCallback((category?: string, search?: string) => {
    33
    setFilterParams({ category, search })
    34
    }, [])

    Also add useMemo to the imports at the top:

    tsx
    1
    import { useState, useCallback, useMemo } from 'react'

    Remove the isFiltering state 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 with filteredPosts from useMemo)
    • Remove const currentTotal = filteredTotal ?? totalPosts (replace with filteredPosts.length)

    Update the useInfiniteScrollWithFetch call:

    tsx
    1
    const {
    2
    items: blogs,
    3
    hasMore,
    4
    loadMoreRef,
    5
    } = useInfiniteScrollWithFetch({
    6
    initialItems: filteredPosts.slice(0, POSTS_PER_PAGE),
    7
    totalItems: filteredPosts.length,
    8
    pageSize: POSTS_PER_PAGE,
    9
    fetchMore: fetchMorePosts,
    10
    rootMargin: '1000px',
    11
    })

    The skeleton loader in JSX checks isFiltering — remove that branch since filtering is now instant:

    tsx
    1
    // REMOVE:
    2
    {isFiltering
    3
    ? Array.from({ length: SKELETON_COUNT }).map(...)
    4
    : blogs?.length ...}
    5
    6
    // REPLACE WITH:
    7
    {blogs?.length
    8
    ? blogs.map((blog: PostTypes, idx: number) => ...)
    9
    : <p className="text-sm text-light col-span-full">No results</p>
    10
    }

    Remove SKELETON_COUNT constant and BlogListItemSkeleton component (no longer used).

  • Step 4: Update BlogFilters usage in BlogClient.tsx

    Derive categories from allPosts and pass them to BlogFilters:

    tsx
    1
    const allCategories = useMemo(() => {
    2
    const cats = new Set<string>()
    3
    for (const post of allPosts) {
    4
    for (const cat of (post.categories ?? [])) {
    5
    cats.add(cat)
    6
    }
    7
    }
    8
    return Array.from(cats).sort((a, b) => a.localeCompare(b))
    9
    }, [allPosts])

    Then in JSX:

    tsx
    1
    // BEFORE:
    2
    <BlogFilters onFilterChange={handleFilterChange} />
    3
    4
    // AFTER:
    5
    <BlogFilters categories={allCategories} onFilterChange={handleFilterChange} />
  • Step 5: Run TypeScript check

    bash
    1
    cd apps/www && pnpm exec tsc --noEmit 2>&1 | head -40

    Fix any TypeScript errors (likely just prop type mismatches from the interface changes).

  • Step 6: Commit

    bash
    1
    git add apps/www/app/blog/BlogClient.tsx \
    2
    apps/www/components/Blog/BlogFilters.tsx \
    3
    apps/www/app/blog/page.tsx
    4
    git 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

    bash
    1
    rm apps/www/app/\(pages\)/opt-out/\[ref\]/page.tsx
    2
    rmdir apps/www/app/\(pages\)/opt-out/\[ref\] 2>/dev/null || true
  • Step 2: Check if opt-out/page.tsx exists

    bash
    1
    ls apps/www/app/\(pages\)/opt-out/

    If page.tsx already 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:

    tsx
    1
    import type { Metadata } from 'next'
    2
    import DefaultLayout from '~/components/Layouts/Default'
    3
    4
    export const metadata: Metadata = {
    5
    title: 'Opt Out | Assistance',
    6
    description: 'Opt out of receiving emails from Assistance.',
    7
    }
    8
    9
    export default function OptOutPage() {
    10
    return (
    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">
    16
    To 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">
    18
    team@assistance.bg
    19
    </a>
    20
    .
    21
    </p>
    22
    </div>
    23
    </div>
    24
    </DefaultLayout>
    25
    )
    26
    }
  • Step 4: Commit

    bash
    1
    git add -A apps/www/app/\(pages\)/opt-out/
    2
    git 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

    bash
    1
    cd apps/www && pnpm build 2>&1 | tee /tmp/www-build.log

    Expected on success: ✓ Generating static pages (N/N) followed by ✓ Finalizing page optimization and exit code 0.

  • Step 2: Fix next/headers uses if any

    If the build fails with errors like "next/headers" is not supported in static exports, find and fix them:

    bash
    1
    grep -rn "from 'next/headers'" apps/www/app/ apps/www/components/

    For each hit:

    • draftMode() → remove the call, hardcode isDraft = false
    • cookies() → remove the call, use a default value
    • headers() → remove the call, use a default value
  • Step 3: Fix any remaining force-dynamic uses

    bash
    1
    grep -rn "force-dynamic" apps/www/app/

    Remove any remaining export const dynamic = 'force-dynamic' lines.

  • Step 4: Fix any remaining revalidate exports

    bash
    1
    grep -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:

    bash
    1
    cd apps/www && pnpm exec tsc --noEmit 2>&1 | head -60

    and fix each error.

  • Step 6: Verify the output directory

    bash
    1
    ls apps/www/out/ | head -20
    2
    ls apps/www/out/_next/static/ | head -5

    Expected: HTML files and _next/static/ directory.

  • Step 7: Commit

    bash
    1
    git add -A apps/www/
    2
    git 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

    bash
    1
    mkdir -p tools/nginx
  • Step 2: Create tools/nginx/www.conf

    nginx
    1
    server {
    2
    listen 3000;
    3
    server_name _;
    4
    5
    root /srv/www/out;
    6
    index index.html;
    7
    8
    # Redirect moved from Next.js middleware
    9
    location = /contact-sales {
    10
    return 308 /contact-us;
    11
    }
    12
    13
    # Static assets are content-hashed — cache forever
    14
    location /_next/static/ {
    15
    add_header Cache-Control "public, max-age=31536000, immutable";
    16
    try_files $uri =404;
    17
    }
    18
    19
    # Public directory assets (images, fonts, etc.)
    20
    location /images/ {
    21
    add_header Cache-Control "public, max-age=86400";
    22
    try_files $uri =404;
    23
    }
    24
    25
    location /fonts/ {
    26
    add_header Cache-Control "public, max-age=31536000, immutable";
    27
    try_files $uri =404;
    28
    }
    29
    30
    # HTML pages — short cache, Cloudflare CDN-Cache-Control handles edge TTL
    31
    location / {
    32
    add_header Cache-Control "public, max-age=3600";
    33
    try_files $uri $uri.html $uri/index.html =404;
    34
    error_page 404 /404.html;
    35
    }
    36
    }
  • Step 3: Commit

    bash
    1
    git add tools/nginx/www.conf
    2
    git 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 www service definition

    bash
    1
    grep -A 20 "^\s*www:" podman-compose.yml

    Note the current image, ports, and environment variables.

  • Step 2: Replace the www service

    Find and replace the www: service block. The new service mounts apps/www/out and the Nginx config:

    yaml
    1
    www:
    2
    image: nginx:alpine
    3
    ports:
    4
    - "3050:3000"
    5
    volumes:
    6
    - ./apps/www/out:/srv/www/out:ro
    7
    - ./tools/nginx/www.conf:/etc/nginx/conf.d/default.conf:ro
    8
    restart: unless-stopped

    Remove any environment:, build:, command:, or Node-specific options from the old www block.

    If the old service has health checks that ping the Node server, remove those too.

  • Step 3: Verify compose file parses

    bash
    1
    podman-compose -f podman-compose.yml config 2>&1 | head -10

    Expected: no errors (config is printed or exits 0).

  • Step 4: Commit

    bash
    1
    git add podman-compose.yml
    2
    git commit -m "feat(www): replace Node standalone container with Nginx static serving"

Self-review checklist#

After all tasks are done, verify:

  • pnpm --filter www build succeeds with exit code 0
  • apps/www/out/ contains HTML files including index.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.ts returns "no such file"