Skip to content

Commit 7dfb83d

Browse files
committed
fix: ISR cold-start image bug, optimize data fetching and caching
1 parent 1c86fc3 commit 7dfb83d

File tree

119 files changed

+925
-900
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

119 files changed

+925
-900
lines changed

.github/workflows/fly-deploy.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,19 @@ jobs:
1313
steps:
1414
- uses: actions/checkout@v4
1515
- uses: superfly/flyctl-actions/setup-flyctl@master
16-
- run: flyctl deploy --remote-only
16+
- run: >
17+
flyctl deploy --remote-only
18+
--build-arg PAYLOAD_SECRET="${PAYLOAD_SECRET}"
19+
--build-arg DATABASE_URI="${DATABASE_URI}"
20+
--build-arg AWS_BUCKET="${AWS_BUCKET}"
21+
--build-arg AWS_REGION="${AWS_REGION}"
22+
--build-arg AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}"
23+
--build-arg AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}"
1724
env:
1825
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
26+
PAYLOAD_SECRET: ${{ secrets.PAYLOAD_SECRET }}
27+
DATABASE_URI: ${{ secrets.DATABASE_URI }}
28+
AWS_BUCKET: ${{ secrets.AWS_BUCKET }}
29+
AWS_REGION: ${{ secrets.AWS_REGION }}
30+
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
31+
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

Dockerfile.prod

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,23 @@ RUN pnpm install --frozen-lockfile --prod=false
3131
# Copy application code
3232
COPY . .
3333

34-
# Build application
34+
# Build-time args so Next.js can pre-render pages with real Payload data
35+
# and configure remotePatterns for S3 image optimization
36+
ARG PAYLOAD_SECRET
37+
ARG DATABASE_URI
38+
ARG AWS_BUCKET
39+
ARG AWS_REGION
40+
ARG AWS_ACCESS_KEY_ID
41+
ARG AWS_SECRET_ACCESS_KEY
42+
43+
ENV PAYLOAD_SECRET=$PAYLOAD_SECRET
44+
ENV DATABASE_URI=$DATABASE_URI
45+
ENV AWS_BUCKET=$AWS_BUCKET
46+
ENV AWS_REGION=$AWS_REGION
47+
ENV AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
48+
ENV AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
49+
50+
# Build application (pages pre-render with real data from Payload)
3551
RUN pnpm run build
3652

3753
# Remove development dependencies

fly.toml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,19 @@ swap_size_mb = 512
1818
min_machines_running = 0
1919
processes = ['app']
2020

21+
[http_service.concurrency]
22+
type = "connections"
23+
hard_limit = 25
24+
soft_limit = 20
25+
26+
[[http_service.checks]]
27+
grace_period = "15s"
28+
interval = "30s"
29+
method = "GET"
30+
timeout = "5s"
31+
path = "/"
32+
2133
[[vm]]
22-
memory = '512mb'
34+
memory = '1024mb'
2335
cpu_kind = 'shared'
2436
cpus = 1

next.config.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,19 @@ import { withPayload } from '@payloadcms/next/withPayload'
44
const AWS_BUCKET = process.env.AWS_BUCKET || ''
55
const AWS_REGION = process.env.AWS_REGION || 'ap-southeast-2'
66

7-
const remotePatterns = []
7+
const remotePatterns = [
8+
// Wildcard fallback for any S3 bucket (safety net)
9+
{
10+
protocol: 'https',
11+
hostname: '*.s3.*.amazonaws.com',
12+
pathname: '/media/**',
13+
},
14+
{
15+
protocol: 'https',
16+
hostname: '*.s3.amazonaws.com',
17+
pathname: '/media/**',
18+
},
19+
]
820

921
if (AWS_BUCKET) {
1022
remotePatterns.push(
@@ -28,7 +40,8 @@ const nextConfig = {
2840
formats: ['image/avif', 'image/webp'],
2941
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
3042
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
31-
minimumCacheTTL: 60,
43+
// Cache optimized images for 1 week — S3 content is immutable (new uploads get new filenames)
44+
minimumCacheTTL: 604800,
3245
},
3346
eslint: {
3447
// Warning: This allows production builds to successfully complete even if

src/app/(frontend)/[...notFound]/page.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import Footer from '@/components/common/Footer'
22
import Header from '@/components/common/Header'
33
import '../global.css'
4-
import feather from '@/assets/big_feather.png'
5-
import kiwi from '@/assets/kiwiBird.svg'
6-
import blueKoru from '@/assets/blue_koru.png'
7-
import koruAndLeaf from '@/assets/koruAndLeaf.png'
8-
import blueWave from '@/assets/blue_wave.png'
4+
import feather from '@/assets/big-feather.png'
5+
import kiwi from '@/assets/kiwi-bird.svg'
6+
import blueKoru from '@/assets/blue-koru.png'
7+
import koruAndLeaf from '@/assets/koru-and-leaf.png'
8+
import blueWave from '@/assets/blue-wave.png'
99
import leaf from '@/assets/leaf.svg'
1010
import Image from 'next/image'
11+
import Link from 'next/link'
1112

1213
const bgImages = [
1314
{
@@ -60,8 +61,8 @@ export default async function NotFound() {
6061

6162
{/* Links out of page - currently links back to 404 not found*/}
6263
<div className="underline font-semibold text-sm mt-5 flex flex-col gap-4 justify-center mt-20 uppercase tracking-wider">
63-
<a href="/">Return to Home</a>
64-
<a href="/NotFound">Explore our Stories and Events</a>
64+
<Link href="/">Return to Home</Link>
65+
<Link href="/events">Explore our Stories and Events</Link>
6566
</div>
6667

6768
{/* All the extras in the background */}

src/app/(frontend)/about/page.tsx

Lines changed: 31 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,17 @@
11
import { Suspense } from 'react'
22
import Header from '@/components/common/Header'
33
import Footer from '@/components/common/Footer'
4-
import MeetTheTeam from '@/components/AboutUs/MeetTheTeam'
4+
import MeetTheTeam from '@/components/about/MeetTheTeam'
55
import Hero from '@/components/about/Hero'
66
import Descriptions from '@/components/about/Descriptions'
77
import MeetTheWDCCTeam from '@/components/about/MeetTheWDCCTeam'
88
import QuotesSection from '@/components/about/QuotesSection'
9-
import { getAboutPageImages } from '@/lib/payload/images'
9+
import { getAllAboutPageImages } from '@/lib/payload/images'
10+
import { getMembers } from '@/lib/payload/members'
1011

11-
// Use dynamic rendering to avoid build-time fetch errors, but cache for 5 minutes in production
12-
export const dynamic = 'force-dynamic'
13-
export const revalidate = 300
12+
// ISR: Revalidate every 30 minutes — content changes infrequently
13+
export const revalidate = 1800
1414

15-
// ✅ Loading fallback
1615
function SectionSkeleton() {
1716
return (
1817
<div className="py-12 px-4">
@@ -21,44 +20,42 @@ function SectionSkeleton() {
2120
)
2221
}
2322

24-
// ✅ Independent async components
25-
async function HeroContent() {
26-
const heroImage = await getAboutPageImages('hero')
27-
return <Hero heroImage={heroImage} />
28-
}
23+
async function AboutContent() {
24+
// Fetch images in one query + members in parallel (2 DB queries instead of 5)
25+
const [allImages, members] = await Promise.all([getAllAboutPageImages(), getMembers()])
26+
27+
// Filter by placement in memory — instant, no extra DB round-trips
28+
const heroImage = allImages.filter((img) => img.placement === 'hero')
29+
const descriptionImage1 = allImages.filter((img) => img.placement === 'description-1')
30+
const descriptionImage2 = allImages.filter((img) => img.placement === 'description-2')
31+
const quoteImage = allImages.filter((img) => img.placement === 'quote')
2932

30-
async function DescriptionsContent() {
31-
const [descriptionImage1, descriptionImage2] = await Promise.all([
32-
getAboutPageImages('description-1'),
33-
getAboutPageImages('description-2'),
34-
])
3533
return (
36-
<Descriptions descriptionImage1={descriptionImage1} descriptionImage2={descriptionImage2} />
34+
<>
35+
<Hero heroImage={heroImage} />
36+
<Descriptions descriptionImage1={descriptionImage1} descriptionImage2={descriptionImage2} />
37+
<MeetTheTeam members={members} />
38+
<QuotesSection quoteImage={quoteImage} />
39+
</>
3740
)
3841
}
3942

40-
async function QuotesContent() {
41-
const quoteImage = await getAboutPageImages('quote')
42-
return <QuotesSection quoteImage={quoteImage} />
43-
}
44-
4543
export default function AboutPage() {
4644
return (
4745
<div className="home">
4846
<Header />
4947

50-
<Suspense fallback={<SectionSkeleton />}>
51-
<HeroContent />
52-
</Suspense>
53-
54-
<Suspense fallback={<SectionSkeleton />}>
55-
<DescriptionsContent />
56-
</Suspense>
57-
58-
<MeetTheTeam />
59-
60-
<Suspense fallback={<SectionSkeleton />}>
61-
<QuotesContent />
48+
<Suspense
49+
fallback={
50+
<>
51+
<SectionSkeleton />
52+
<SectionSkeleton />
53+
<SectionSkeleton />
54+
<SectionSkeleton />
55+
</>
56+
}
57+
>
58+
<AboutContent />
6259
</Suspense>
6360

6461
<MeetTheWDCCTeam />
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { revalidatePath } from 'next/cache'
2+
import { NextRequest, NextResponse } from 'next/server'
3+
4+
export async function POST(request: NextRequest) {
5+
const secret = request.headers.get('x-revalidate-secret')
6+
7+
if (secret !== process.env.REVALIDATE_SECRET) {
8+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9+
}
10+
11+
const body = await request.json()
12+
const { collection, slug } = body
13+
14+
switch (collection) {
15+
case 'blogs':
16+
revalidatePath('/blogs', 'page')
17+
if (slug) {
18+
revalidatePath(`/blogs/${slug}`, 'page')
19+
}
20+
break
21+
case 'events':
22+
revalidatePath('/events', 'page')
23+
revalidatePath('/', 'page')
24+
break
25+
case 'home-page-images':
26+
revalidatePath('/', 'page')
27+
break
28+
case 'about-page-images':
29+
revalidatePath('/about', 'page')
30+
break
31+
case 'members':
32+
revalidatePath('/about', 'page')
33+
break
34+
default:
35+
revalidatePath('/', 'layout')
36+
}
37+
38+
return NextResponse.json({ revalidated: true })
39+
}

src/app/(frontend)/blogs/[slug]/page.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,20 @@ import Footer from '@/components/common/Footer'
44
import { notFound } from 'next/navigation'
55
import { getBlogBySlug } from '@/lib/payload/blogs'
66
import { BLOG_TEMPLATES } from '@/lib/blog-templates'
7+
import { getBlogs } from '@/lib/payload/blogs'
78

8-
// Use dynamic rendering to avoid build-time fetch errors, but cache for 10 minutes in production
9-
export const dynamic = 'force-dynamic'
10-
export const revalidate = 600
9+
// ISR: Revalidate every 1 hour — individual blog posts rarely change
10+
export const revalidate = 3600
11+
12+
// Generate static pages for all published blogs at build time
13+
export async function generateStaticParams() {
14+
try {
15+
const { docs } = await getBlogs({ limit: 100 })
16+
return docs.map((blog) => ({ slug: blog.slug }))
17+
} catch {
18+
return []
19+
}
20+
}
1121

1222
export default async function BlogPage({ params }: { params: Promise<{ slug: string }> }) {
1323
const { slug } = await params

src/app/(frontend)/blogs/page.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import React from 'react'
22
import Header from '@/components/common/Header'
33
import Footer from '@/components/common/Footer'
4-
import BlogCard from '@/components/blogs/BlogCard'
54
import Search from '@/components/blogs/Search'
65
import { getBlogs } from '@/lib/payload/blogs'
7-
import type { BlogType } from '@/types/blog'
86

9-
// Use dynamic rendering to avoid build-time fetch errors, but cache for 5 minutes in production
10-
export const dynamic = 'force-dynamic'
11-
export const revalidate = 300
7+
// ISR: Revalidate every 30 minutes — content changes infrequently
8+
export const revalidate = 1800
129

1310
export default async function BlogsPage() {
1411
const { docs: blogs } = await getBlogs({ page: 1, limit: 10 })

0 commit comments

Comments
 (0)