Static Generation
Static Site Generation (SSG) pre-renders pages at build time, creating fast-loading static HTML files. PGRestify enhances SSG with database-driven content, Incremental Static Regeneration (ISR), and on-demand revalidation for optimal performance and SEO.
Overview
PGRestify SSG features:
- Build-Time Generation: Pre-render pages with database data
- Incremental Static Regeneration: Update static pages after deployment
- On-Demand Revalidation: Trigger updates when content changes
- Dynamic Static Paths: Generate paths based on database content
- Partial Pre-rendering: Mix static and server-rendered content
- CDN Optimization: Serve static files globally
- SEO Benefits: Perfect meta tags and structured data
Basic Static Generation
getStaticProps with PGRestify
typescript
// pages/posts/index.tsx
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import { createGetStaticProps } from '@webcoded/pgrestify/nextjs';
import Link from 'next/link';
import Head from 'next/head';
interface StaticPostsPageProps {
posts: {
id: string;
title: string;
excerpt: string;
slug: string;
created_at: string;
reading_time: number;
author: {
id: string;
name: string;
avatar_url: string;
};
category: {
id: string;
name: string;
color: string;
};
}[];
categories: {
id: string;
name: string;
post_count: number;
}[];
totalPosts: number;
lastUpdated: string;
}
export default function StaticPostsPage({
posts,
categories,
totalPosts,
lastUpdated
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<>
<Head>
<title>Blog Posts - Latest Articles and Insights</title>
<meta name="description" content={`Browse our ${totalPosts} articles covering technology, design, and development.`} />
<meta property="og:title" content="Blog Posts - Latest Articles" />
<meta property="og:description" content={`Browse our ${totalPosts} articles covering technology, design, and development.`} />
<meta property="og:type" content="website" />
<link rel="canonical" href="https://yourdomain.com/posts" />
{/* Structured data for SEO */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Blog',
name: 'Our Blog',
description: 'Latest articles and insights',
url: 'https://yourdomain.com/posts',
author: {
'@type': 'Organization',
name: 'Your Company'
},
blogPost: posts.map(post => ({
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
url: `https://yourdomain.com/posts/${post.slug}`,
datePublished: post.created_at,
author: {
'@type': 'Person',
name: post.author.name
}
}))
})
}}
/>
</Head>
<div className="container mx-auto px-4 py-8">
<header className="mb-12 text-center">
<h1 className="text-4xl font-bold mb-4">Latest Posts</h1>
<p className="text-gray-600 text-lg">
Discover our {totalPosts} articles across {categories.length} categories
</p>
<p className="text-sm text-gray-500 mt-2">
Last updated: {new Date(lastUpdated).toLocaleDateString()}
</p>
</header>
{/* Categories */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Categories</h2>
<div className="flex flex-wrap gap-3">
{categories.map(category => (
<Link
key={category.id}
href={`/categories/${category.name.toLowerCase()}`}
className="px-4 py-2 bg-gray-100 rounded-full hover:bg-gray-200 transition-colors"
>
{category.name} ({category.post_count})
</Link>
))}
</div>
</section>
{/* Posts grid */}
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map(post => (
<article key={post.id} className="bg-white rounded-lg shadow-md overflow-hidden">
<div className="p-6">
<div className="flex items-center gap-2 mb-4">
<span
className="px-2 py-1 rounded text-xs font-medium text-white"
style={{ backgroundColor: post.category.color }}
>
{post.category.name}
</span>
<span className="text-xs text-gray-500">
{post.reading_time} min read
</span>
</div>
<h2 className="text-xl font-semibold mb-3">
<Link
href={`/posts/${post.slug}`}
className="hover:text-blue-600 transition-colors"
>
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4 line-clamp-3">{post.excerpt}</p>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<img
src={post.author.avatar_url}
alt={post.author.name}
className="w-8 h-8 rounded-full"
/>
<span className="text-sm text-gray-700">{post.author.name}</span>
</div>
<time className="text-sm text-gray-500">
{new Date(post.created_at).toLocaleDateString()}
</time>
</div>
</div>
</article>
))}
</div>
</div>
</>
);
}
export const getStaticProps: GetStaticProps<StaticPostsPageProps> = createGetStaticProps(
async ({ client }) => {
// Fetch posts with all related data
const { data: posts, error: postsError } = await client
.from('posts')
.select(`
id,
title,
excerpt,
slug,
created_at,
reading_time,
author:users(id, name, avatar_url),
category:categories(id, name, color)
`)
.eq('published', true)
.order('created_at', { ascending: false })
.limit(50); // Limit for performance
if (postsError) {
throw new Error(`Failed to fetch posts: ${postsError.message}`);
}
// Fetch categories with post counts
const { data: categories, error: categoriesError } = await client
.from('categories')
.select(`
id,
name,
posts:posts(count)
`)
.order('name');
if (categoriesError) {
throw new Error(`Failed to fetch categories: ${categoriesError.message}`);
}
// Get total posts count
const { count: totalPosts } = await client
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('published', true);
return {
props: {
posts: posts || [],
categories: categories?.map(cat => ({
id: cat.id,
name: cat.name,
post_count: cat.posts[0].count
})) || [],
totalPosts: totalPosts || 0,
lastUpdated: new Date().toISOString()
},
// Revalidate every hour
revalidate: 3600
};
}
);
Dynamic Static Generation
typescript
// pages/posts/[slug].tsx
import { GetStaticProps, GetStaticPaths, InferGetStaticPropsType } from 'next';
import { createGetStaticProps } from '@webcoded/pgrestify/nextjs';
import Head from 'next/head';
interface StaticPostPageProps {
post: {
id: string;
title: string;
content: string;
excerpt: string;
slug: string;
featured_image?: string;
created_at: string;
updated_at: string;
reading_time: number;
view_count: number;
author: {
id: string;
name: string;
bio: string;
avatar_url: string;
social_links?: Record<string, string>;
};
category: {
id: string;
name: string;
color: string;
description: string;
};
tags: {
id: string;
name: string;
color: string;
}[];
seo: {
meta_title?: string;
meta_description?: string;
canonical_url?: string;
};
};
relatedPosts: {
id: string;
title: string;
slug: string;
excerpt: string;
created_at: string;
author: {
name: string;
avatar_url: string;
};
}[];
}
export default function StaticPostPage({
post,
relatedPosts
}: InferGetStaticPropsType<typeof getStaticProps>) {
const {
title,
content,
excerpt,
featured_image,
created_at,
updated_at,
reading_time,
view_count,
author,
category,
tags,
seo
} = post;
return (
<>
<Head>
<title>{seo.meta_title || title}</title>
<meta name="description" content={seo.meta_description || excerpt} />
<link rel="canonical" href={seo.canonical_url || `https://yourdomain.com/posts/${post.slug}`} />
{/* Open Graph */}
<meta property="og:title" content={title} />
<meta property="og:description" content={excerpt} />
<meta property="og:type" content="article" />
<meta property="og:url" content={`https://yourdomain.com/posts/${post.slug}`} />
{featured_image && <meta property="og:image" content={featured_image} />}
<meta property="article:published_time" content={created_at} />
<meta property="article:modified_time" content={updated_at} />
<meta property="article:author" content={author.name} />
<meta property="article:section" content={category.name} />
{tags.map(tag => (
<meta key={tag.id} property="article:tag" content={tag.name} />
))}
{/* Twitter Card */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={excerpt} />
{featured_image && <meta name="twitter:image" content={featured_image} />}
{/* Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description: excerpt,
image: featured_image,
url: `https://yourdomain.com/posts/${post.slug}`,
datePublished: created_at,
dateModified: updated_at,
author: {
'@type': 'Person',
name: author.name,
description: author.bio,
image: author.avatar_url,
sameAs: author.social_links ? Object.values(author.social_links) : []
},
publisher: {
'@type': 'Organization',
name: 'Your Company',
logo: {
'@type': 'ImageObject',
url: 'https://yourdomain.com/logo.png'
}
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://yourdomain.com/posts/${post.slug}`
},
wordCount: content.split(' ').length,
keywords: tags.map(tag => tag.name).join(', ')
})
}}
/>
</Head>
<article className="max-w-4xl mx-auto px-4 py-8">
{/* Article Header */}
<header className="mb-8">
<div className="flex items-center gap-2 mb-4">
<span
className="px-3 py-1 rounded-full text-sm font-medium text-white"
style={{ backgroundColor: category.color }}
>
{category.name}
</span>
{tags.map(tag => (
<span
key={tag.id}
className="px-2 py-1 rounded text-xs font-medium"
style={{ backgroundColor: tag.color, color: 'white' }}
>
#{tag.name}
</span>
))}
</div>
<h1 className="text-4xl md:text-5xl font-bold mb-4 leading-tight">
{title}
</h1>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<img
src={author.avatar_url}
alt={author.name}
className="w-12 h-12 rounded-full"
/>
<div>
<div className="font-medium text-lg">{author.name}</div>
<div className="text-gray-600">{author.bio}</div>
</div>
</div>
<div className="text-right text-sm text-gray-600">
<time dateTime={created_at}>
{new Date(created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<div>{reading_time} min read</div>
<div>{view_count.toLocaleString()} views</div>
</div>
</div>
</header>
{/* Featured Image */}
{featured_image && (
<div className="mb-8">
<img
src={featured_image}
alt={title}
className="w-full h-64 md:h-96 object-cover rounded-lg shadow-lg"
/>
</div>
)}
{/* Article Content */}
<div
className="prose prose-lg max-w-none mb-12"
dangerouslySetInnerHTML={{ __html: content }}
/>
{/* Related Posts */}
{relatedPosts.length > 0 && (
<section className="border-t pt-8">
<h2 className="text-2xl font-bold mb-6">Related Posts</h2>
<div className="grid gap-6 md:grid-cols-2">
{relatedPosts.map(relatedPost => (
<article key={relatedPost.id} className="bg-gray-50 p-6 rounded-lg">
<h3 className="text-xl font-semibold mb-2">
<Link href={`/posts/${relatedPost.slug}`}>
{relatedPost.title}
</Link>
</h3>
<p className="text-gray-600 mb-4">{relatedPost.excerpt}</p>
<div className="flex items-center gap-2 text-sm text-gray-500">
<img
src={relatedPost.author.avatar_url}
alt={relatedPost.author.name}
className="w-6 h-6 rounded-full"
/>
<span>{relatedPost.author.name}</span>
<span>•</span>
<time>{new Date(relatedPost.created_at).toLocaleDateString()}</time>
</div>
</article>
))}
</div>
</section>
)}
</article>
</>
);
}
export const getStaticProps: GetStaticProps<StaticPostPageProps> = createGetStaticProps(
async ({ client, params }) => {
const slug = params?.slug as string;
// Get main post data
const { data: post, error } = await client
.from('posts')
.select(`
id,
title,
content,
excerpt,
slug,
featured_image,
created_at,
updated_at,
reading_time,
view_count,
category_id,
author:users(
id,
name,
bio,
avatar_url,
social_links
),
category:categories(
id,
name,
color,
description
),
tags:post_tags(
tag:tags(id, name, color)
),
seo:post_seo(
meta_title,
meta_description,
canonical_url
)
`)
.eq('slug', slug)
.eq('published', true)
.single();
if (error || !post) {
return {
notFound: true
};
}
// Get related posts from same category
const { data: relatedPosts } = await client
.from('posts')
.select(`
id,
title,
slug,
excerpt,
created_at,
author:users(name, avatar_url)
`)
.eq('category_id', post.category_id)
.eq('published', true)
.neq('id', post.id)
.limit(4);
return {
props: {
post: {
...post,
tags: post.tags?.map(pt => pt.tag) || [],
seo: post.seo || {}
},
relatedPosts: relatedPosts || []
},
// Revalidate every 6 hours
revalidate: 21600
};
}
);
export const getStaticPaths: GetStaticPaths = async () => {
const client = createServerClient();
// Only pre-generate most popular posts at build time
const { data: posts } = await client
.from('posts')
.select('slug')
.eq('published', true)
.order('view_count', { ascending: false })
.limit(100); // Top 100 most viewed posts
const paths = posts?.map(post => ({
params: { slug: post.slug }
})) || [];
return {
paths,
// Enable ISR for other posts
fallback: 'blocking'
};
};
Incremental Static Regeneration (ISR)
Time-based Revalidation
typescript
// pages/dashboard/stats.tsx
import { GetStaticProps, InferGetStaticPropsType } from 'next';
import { createGetStaticProps } from '@webcoded/pgrestify/nextjs';
interface StatsPageProps {
stats: {
totalPosts: number;
totalUsers: number;
totalComments: number;
viewsThisMonth: number;
topCategories: {
name: string;
post_count: number;
}[];
recentActivity: {
type: string;
description: string;
created_at: string;
}[];
};
generatedAt: string;
}
export default function StatsPage({
stats,
generatedAt
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-6">
<h1 className="text-3xl font-bold">Site Statistics</h1>
<p className="text-gray-600">
Generated at: {new Date(generatedAt).toLocaleString()}
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-700">Total Posts</h3>
<p className="text-3xl font-bold text-blue-600">{stats.totalPosts}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-700">Total Users</h3>
<p className="text-3xl font-bold text-green-600">{stats.totalUsers}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-700">Total Comments</h3>
<p className="text-3xl font-bold text-purple-600">{stats.totalComments}</p>
</div>
<div className="bg-white p-6 rounded-lg shadow">
<h3 className="text-lg font-semibold text-gray-700">Views This Month</h3>
<p className="text-3xl font-bold text-orange-600">{stats.viewsThisMonth}</p>
</div>
</div>
<div className="grid md:grid-cols-2 gap-8">
{/* Top Categories */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Top Categories</h2>
<div className="space-y-3">
{stats.topCategories.map((category, index) => (
<div key={category.name} className="flex justify-between items-center">
<span className="font-medium">
#{index + 1} {category.name}
</span>
<span className="text-gray-600">{category.post_count} posts</span>
</div>
))}
</div>
</div>
{/* Recent Activity */}
<div className="bg-white p-6 rounded-lg shadow">
<h2 className="text-xl font-semibold mb-4">Recent Activity</h2>
<div className="space-y-3">
{stats.recentActivity.map((activity, index) => (
<div key={index} className="border-l-2 border-blue-500 pl-3">
<p className="font-medium">{activity.type}</p>
<p className="text-sm text-gray-600">{activity.description}</p>
<p className="text-xs text-gray-500">
{new Date(activity.created_at).toLocaleString()}
</p>
</div>
))}
</div>
</div>
</div>
</div>
);
}
export const getStaticProps: GetStaticProps<StatsPageProps> = createGetStaticProps(
async ({ client }) => {
// Run all queries in parallel for better performance
const [
{ count: totalPosts },
{ count: totalUsers },
{ count: totalComments },
{ data: topCategories },
{ data: recentActivity }
] = await Promise.all([
client.from('posts').select('*', { count: 'exact', head: true }).eq('published', true),
client.from('users').select('*', { count: 'exact', head: true }),
client.from('comments').select('*', { count: 'exact', head: true }),
client
.from('categories')
.select('name, posts:posts(count)')
.order('posts.count', { ascending: false })
.limit(5),
client
.from('activity_log')
.select('type, description, created_at')
.order('created_at', { ascending: false })
.limit(10)
]);
// Calculate views this month (assuming you have a views table)
const startOfMonth = new Date();
startOfMonth.setDate(1);
startOfMonth.setHours(0, 0, 0, 0);
const { count: viewsThisMonth } = await client
.from('post_views')
.select('*', { count: 'exact', head: true })
.gte('created_at', startOfMonth.toISOString());
return {
props: {
stats: {
totalPosts: totalPosts || 0,
totalUsers: totalUsers || 0,
totalComments: totalComments || 0,
viewsThisMonth: viewsThisMonth || 0,
topCategories: topCategories?.map(cat => ({
name: cat.name,
post_count: cat.posts[0].count
})) || [],
recentActivity: recentActivity || []
},
generatedAt: new Date().toISOString()
},
// Revalidate every 15 minutes
revalidate: 900
};
}
);
On-Demand Revalidation
typescript
// pages/api/revalidate.ts
import { NextApiRequest, NextApiResponse } from 'next/server';
import { createRouteHandler } from '@webcoded/pgrestify/nextjs';
export default createRouteHandler({
POST: async ({ req, res }) => {
// Validate revalidation secret
if (req.query.secret !== process.env.REVALIDATION_SECRET) {
return res.status(401).json({ error: 'Invalid secret' });
}
const { paths, tags } = req.body;
try {
// Revalidate specific paths
if (paths && Array.isArray(paths)) {
await Promise.all(
paths.map((path: string) => res.revalidate(path))
);
}
// Revalidate by tags (App Router)
if (tags && Array.isArray(tags)) {
const { revalidateTag } = await import('next/cache');
tags.forEach((tag: string) => revalidateTag(tag));
}
return res.json({
revalidated: true,
paths: paths || [],
tags: tags || [],
timestamp: new Date().toISOString()
});
} catch (error) {
return res.status(500).json({
error: 'Error revalidating',
details: error.message
});
}
}
});
// Webhook handler for content changes
// pages/api/webhooks/content-updated.ts
export default createRouteHandler({
POST: async ({ client, req, res }) => {
const { entity, action, id } = req.body;
try {
let pathsToRevalidate: string[] = [];
switch (entity) {
case 'post':
if (action === 'created' || action === 'updated' || action === 'deleted') {
// Get post slug for specific revalidation
if (id && (action === 'updated' || action === 'deleted')) {
const { data: post } = await client
.from('posts')
.select('slug, category_id')
.eq('id', id)
.single();
if (post) {
pathsToRevalidate.push(`/posts/${post.slug}`);
// Also revalidate category page
const { data: category } = await client
.from('categories')
.select('name')
.eq('id', post.category_id)
.single();
if (category) {
pathsToRevalidate.push(`/categories/${category.name.toLowerCase()}`);
}
}
}
// Always revalidate posts index
pathsToRevalidate.push('/posts');
pathsToRevalidate.push('/');
}
break;
case 'category':
if (action === 'created' || action === 'updated' || action === 'deleted') {
pathsToRevalidate.push('/posts');
pathsToRevalidate.push('/');
if (id && (action === 'updated' || action === 'deleted')) {
const { data: category } = await client
.from('categories')
.select('name')
.eq('id', id)
.single();
if (category) {
pathsToRevalidate.push(`/categories/${category.name.toLowerCase()}`);
}
}
}
break;
default:
// For unknown entities, revalidate homepage
pathsToRevalidate.push('/');
}
// Trigger revalidation
if (pathsToRevalidate.length > 0) {
await Promise.all(
pathsToRevalidate.map(path => res.revalidate(path))
);
}
return res.json({
success: true,
revalidated: pathsToRevalidate,
entity,
action,
timestamp: new Date().toISOString()
});
} catch (error) {
console.error('Revalidation error:', error);
return res.status(500).json({
error: 'Revalidation failed',
details: error.message
});
}
}
});
App Router Static Generation
Static Page Generation
typescript
// app/posts/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';
import { Metadata } from 'next';
// Enable static generation
export const dynamic = 'force-static';
export const revalidate = 3600; // Revalidate every hour
interface PostsPageData {
posts: Post[];
categories: Category[];
totalCount: number;
}
async function getPostsData(): Promise<PostsPageData> {
const client = createServerClient();
const [
{ data: posts },
{ data: categories },
{ count: totalCount }
] = await Promise.all([
client
.from('posts')
.select(`
id,
title,
excerpt,
slug,
created_at,
author:users(id, name, avatar_url),
category:categories(id, name, color)
`)
.eq('published', true)
.order('created_at', { ascending: false })
.limit(50),
client
.from('categories')
.select('id, name, color')
.order('name'),
client
.from('posts')
.select('*', { count: 'exact', head: true })
.eq('published', true)
]);
return {
posts: posts || [],
categories: categories || [],
totalCount: totalCount || 0
};
}
export async function generateMetadata(): Promise<Metadata> {
const { totalCount } = await getPostsData();
return {
title: 'Blog Posts - Latest Articles',
description: `Browse our collection of ${totalCount} articles covering technology, design, and development.`,
openGraph: {
title: 'Blog Posts - Latest Articles',
description: `Browse our collection of ${totalCount} articles covering technology, design, and development.`,
type: 'website'
}
};
}
export default async function PostsPage() {
const { posts, categories, totalCount } = await getPostsData();
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-12 text-center">
<h1 className="text-4xl font-bold mb-4">Latest Posts</h1>
<p className="text-gray-600 text-lg">
Discover our {totalCount} articles across {categories.length} categories
</p>
</header>
{/* Categories */}
<section className="mb-8">
<h2 className="text-2xl font-semibold mb-4">Categories</h2>
<div className="flex flex-wrap gap-3">
{categories.map(category => (
<Link
key={category.id}
href={`/categories/${category.name.toLowerCase()}`}
className="px-4 py-2 bg-gray-100 rounded-full hover:bg-gray-200 transition-colors"
>
{category.name}
</Link>
))}
</div>
</section>
{/* Posts */}
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-3">
{posts.map(post => (
<PostCard key={post.id} post={post} />
))}
</div>
</div>
);
}
Dynamic Static Routes
typescript
// app/posts/[slug]/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface PostPageProps {
params: { slug: string };
}
// Enable static generation
export const dynamic = 'force-static';
export const dynamicParams = true; // Allow dynamic params not in generateStaticParams
export const revalidate = 21600; // 6 hours
async function getPost(slug: string) {
const client = createServerClient();
const { data: post, error } = await client
.from('posts')
.select(`
*,
author:users(id, name, bio, avatar_url, social_links),
category:categories(id, name, color, description),
tags:post_tags(tag:tags(id, name, color)),
seo:post_seo(meta_title, meta_description, canonical_url)
`)
.eq('slug', slug)
.eq('published', true)
.single();
if (error || !post) {
return null;
}
return {
...post,
tags: post.tags?.map(pt => pt.tag) || [],
seo: post.seo || {}
};
}
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) {
return {
title: 'Post Not Found'
};
}
return {
title: post.seo.meta_title || post.title,
description: post.seo.meta_description || post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.created_at,
modifiedTime: post.updated_at,
authors: [post.author.name],
images: post.featured_image ? [post.featured_image] : undefined
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: post.featured_image ? [post.featured_image] : undefined
},
alternates: {
canonical: post.seo.canonical_url
}
};
}
export default async function PostPage({ params }: PostPageProps) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article className="max-w-4xl mx-auto px-4 py-8">
{/* Post content */}
<PostContent post={post} />
</article>
);
}
export async function generateStaticParams() {
const client = createServerClient();
// Generate params for most popular posts
const { data: posts } = await client
.from('posts')
.select('slug')
.eq('published', true)
.order('view_count', { ascending: false })
.limit(100);
return posts?.map(post => ({
slug: post.slug
})) || [];
}
Performance Optimization
Build-Time Optimization
typescript
// lib/static-data.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs';
// Cache frequently accessed data at build time
export async function getStaticSiteData() {
const client = createServerClient();
const [
{ data: featuredPosts },
{ data: categories },
{ data: tags },
{ data: siteSettings }
] = await Promise.all([
client
.from('posts')
.select(`
id,
title,
slug,
excerpt,
featured_image,
created_at,
author:users(name, avatar_url),
category:categories(name, color)
`)
.eq('featured', true)
.eq('published', true)
.order('created_at', { ascending: false })
.limit(5),
client
.from('categories')
.select('id, name, slug, color, description')
.order('name'),
client
.from('tags')
.select('id, name, slug, color')
.order('name')
.limit(50),
client
.from('site_settings')
.select('*')
.single()
]);
return {
featuredPosts: featuredPosts || [],
categories: categories || [],
tags: tags || [],
siteSettings: siteSettings || {}
};
}
// Generate sitemap data
export async function generateSitemapData() {
const client = createServerClient();
const [
{ data: posts },
{ data: categories },
{ data: staticPages }
] = await Promise.all([
client
.from('posts')
.select('slug, updated_at')
.eq('published', true),
client
.from('categories')
.select('slug, updated_at'),
client
.from('pages')
.select('slug, updated_at')
.eq('published', true)
]);
return {
posts: posts || [],
categories: categories || [],
pages: staticPages || []
};
}
RSS Feed Generation
typescript
// app/feed.xml/route.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs';
export const dynamic = 'force-static';
export const revalidate = 3600;
function generateRSS(posts: any[], siteInfo: any) {
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${siteInfo.title}</title>
<description>${siteInfo.description}</description>
<link>${siteInfo.url}</link>
<atom:link href="${siteInfo.url}/feed.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${posts.map(post => `
<item>
<title><![CDATA[${post.title}]]></title>
<description><![CDATA[${post.excerpt}]]></description>
<link>${siteInfo.url}/posts/${post.slug}</link>
<guid isPermaLink="true">${siteInfo.url}/posts/${post.slug}</guid>
<pubDate>${new Date(post.created_at).toUTCString()}</pubDate>
<category><![CDATA[${post.category.name}]]></category>
<author>${post.author.email} (${post.author.name})</author>
</item>
`).join('')}
</channel>
</rss>`;
return rss.trim();
}
export async function GET() {
const client = createServerClient();
const [
{ data: posts },
{ data: siteSettings }
] = await Promise.all([
client
.from('posts')
.select(`
title,
excerpt,
slug,
created_at,
author:users(name, email),
category:categories(name)
`)
.eq('published', true)
.order('created_at', { ascending: false })
.limit(50),
client
.from('site_settings')
.select('title, description, url')
.single()
]);
const rss = generateRSS(posts || [], siteSettings || {});
return new Response(rss, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 's-maxage=3600, stale-while-revalidate'
}
});
}
Sitemap Generation
typescript
// app/sitemap.xml/route.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs';
export const dynamic = 'force-static';
export const revalidate = 86400; // 24 hours
function generateSitemap(data: any) {
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${process.env.NEXT_PUBLIC_SITE_URL}</loc>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${process.env.NEXT_PUBLIC_SITE_URL}/posts</loc>
<changefreq>daily</changefreq>
<priority>0.9</priority>
</url>
${data.posts.map((post: any) => `
<url>
<loc>${process.env.NEXT_PUBLIC_SITE_URL}/posts/${post.slug}</loc>
<lastmod>${new Date(post.updated_at).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
`).join('')}
${data.categories.map((category: any) => `
<url>
<loc>${process.env.NEXT_PUBLIC_SITE_URL}/categories/${category.slug}</loc>
<lastmod>${new Date(category.updated_at).toISOString()}</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
`).join('')}
</urlset>`;
return sitemap.trim();
}
export async function GET() {
const client = createServerClient();
const [
{ data: posts },
{ data: categories }
] = await Promise.all([
client
.from('posts')
.select('slug, updated_at')
.eq('published', true),
client
.from('categories')
.select('slug, updated_at')
]);
const sitemap = generateSitemap({
posts: posts || [],
categories: categories || []
});
return new Response(sitemap, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 's-maxage=86400, stale-while-revalidate'
}
});
}
Best Practices
Static Generation Strategy
- Pre-render Popular Content: Generate high-traffic pages at build time
- Use ISR for Updates: Enable automatic updates without full rebuilds
- Fallback for Long Tail: Use
fallback: 'blocking'
for less popular content - Cache Optimization: Set appropriate revalidation times
- SEO Optimization: Include structured data and meta tags
Performance Tips
typescript
// Optimize database queries for static generation
export const getStaticProps: GetStaticProps = createGetStaticProps(
async ({ client }) => {
// Use parallel queries for better performance
const [posts, categories, stats] = await Promise.all([
client.from('posts').select('*').eq('published', true).limit(50),
client.from('categories').select('*'),
client.from('posts').select('*', { count: 'exact', head: true })
]);
return {
props: {
posts: posts.data || [],
categories: categories.data || [],
totalPosts: stats.count || 0
},
revalidate: 3600
};
}
);
Error Handling
typescript
// Handle errors gracefully in static generation
export const getStaticProps: GetStaticProps = createGetStaticProps(
async ({ client }) => {
try {
const { data: posts, error } = await client
.from('posts')
.select('*')
.eq('published', true);
if (error) {
console.error('Static generation error:', error);
// Return fallback data or throw to show error page
return {
props: {
posts: [],
error: 'Failed to load posts'
},
revalidate: 60 // Retry in 1 minute
};
}
return {
props: { posts: posts || [] },
revalidate: 3600
};
} catch (error) {
console.error('Unexpected error:', error);
throw error; // This will show the error page
}
}
);
Deployment Considerations
Build Performance
bash
# Environment variables for build optimization
NEXT_BUILD_ONLY=true
NEXT_STATIC_ONLY=true
# Build command
npm run build && npm run export
CDN Configuration
Configure your CDN to cache static assets and HTML files appropriately:
javascript
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/(.*)',
headers: [
{
key: 'Cache-Control',
value: 's-maxage=86400, stale-while-revalidate=604800'
}
]
}
];
}
};
Next Steps
- Server-Side Rendering - Advanced SSR patterns
- API Routes - Building robust API endpoints
- Authentication - Auth integration with SSG