Next.js Server-Side Rendering (SSR)
Server-Side Rendering generates pages on each request, ensuring fresh data and optimal SEO performance.
Setup
typescript
// lib/client.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs'
export const serverClient = createServerClient({
url: process.env.POSTGREST_URL!,
auth: {
persistSession: false, // SSR doesn't need session persistence
autoRefreshToken: false
}
})
export const clientClient = createClientClient({
url: process.env.NEXT_PUBLIC_POSTGREST_URL!,
auth: {
persistSession: true
}
})
Pages Router SSR
Basic getServerSideProps
typescript
// pages/users/index.tsx
import { GetServerSideProps } from 'next'
import { serverClient } from '../../lib/client'
interface User {
id: number
name: string
email: string
lastLogin: string
}
interface UsersPageProps {
users: User[]
timestamp: string
}
export default function UsersPage({ users, timestamp }: UsersPageProps) {
return (
<div>
<h1>Users (Updated: {new Date(timestamp).toLocaleString()})</h1>
{users.map(user => (
<div key={user.id}>
<h2>{user.name}</h2>
<p>Email: {user.email}</p>
<p>Last Login: {new Date(user.lastLogin).toLocaleString()}</p>
</div>
))}
</div>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
try {
const result = await serverClient
.from<User>('users')
.select('id', 'name', 'email', 'lastLogin')
.order('lastLogin', { ascending: false })
.execute()
return {
props: {
users: result.data || [],
timestamp: new Date().toISOString()
}
}
} catch (error) {
console.error('Failed to fetch users:', error)
return {
props: {
users: [],
timestamp: new Date().toISOString()
}
}
}
}
Dynamic Routes with SSR
typescript
// pages/users/[id].tsx
import { GetServerSideProps } from 'next'
import { serverClient } from '../../lib/client'
interface UserProfile {
id: number
name: string
email: string
bio: string
posts: {
id: number
title: string
publishedAt: string
}[]
}
export default function UserProfilePage({ user }: { user: UserProfile | null }) {
if (!user) {
return <div>User not found</div>
}
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<div>
<h2>Bio</h2>
<p>{user.bio}</p>
</div>
<div>
<h2>Recent Posts</h2>
{user.posts.map(post => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>Published: {new Date(post.publishedAt).toLocaleDateString()}</p>
</article>
))}
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params!
try {
// Fetch user with related posts
const [userResult, postsResult] = await Promise.all([
serverClient
.from('users')
.select('id', 'name', 'email', 'bio')
.eq('id', id)
.single()
.execute(),
serverClient
.from('posts')
.select('id', 'title', 'publishedAt')
.eq('authorId', id)
.order('publishedAt', { ascending: false })
.limit(5)
.execute()
])
if (!userResult.data) {
return { notFound: true }
}
const user: UserProfile = {
...userResult.data,
posts: postsResult.data || []
}
return {
props: { user }
}
} catch (error) {
console.error('Failed to fetch user:', error)
return { notFound: true }
}
}
Authentication-Aware SSR
typescript
// pages/dashboard.tsx
import { GetServerSideProps } from 'next'
import { serverClient } from '../lib/client'
import { verifyToken } from '../lib/auth'
interface DashboardProps {
user: {
id: number
name: string
role: string
}
stats: {
totalPosts: number
totalViews: number
totalComments: number
}
}
export default function DashboardPage({ user, stats }: DashboardProps) {
return (
<div>
<h1>Welcome, {user.name}</h1>
<div>
<h2>Your Statistics</h2>
<div>Posts: {stats.totalPosts}</div>
<div>Views: {stats.totalViews}</div>
<div>Comments: {stats.totalComments}</div>
</div>
</div>
)
}
export const getServerSideProps: GetServerSideProps = async (context) => {
const token = context.req.cookies.authToken
if (!token) {
return {
redirect: {
destination: '/login',
permanent: false
}
}
}
try {
// Verify token and get user
const payload = await verifyToken(token)
// Fetch user data and statistics
const [userResult, statsResult] = await Promise.all([
serverClient
.from('users')
.select('id', 'name', 'role')
.eq('id', payload.userId)
.single()
.execute(),
serverClient
.rpc('get_user_stats', { user_id: payload.userId })
.single()
.execute()
])
if (!userResult.data) {
return {
redirect: {
destination: '/login',
permanent: false
}
}
}
return {
props: {
user: userResult.data,
stats: statsResult.data || { totalPosts: 0, totalViews: 0, totalComments: 0 }
}
}
} catch (error) {
return {
redirect: {
destination: '/login',
permanent: false
}
}
}
}
App Router SSR
Server Components with Dynamic Data
typescript
// app/users/page.tsx
import { serverClient } from '../../lib/client'
import { headers } from 'next/headers'
interface User {
id: number
name: string
email: string
status: 'active' | 'inactive'
}
export default async function UsersPage() {
// Headers ensure this runs on each request
const headersList = headers()
const result = await serverClient
.from<User>('users')
.select('id', 'name', 'email', 'status')
.execute({
cache: 'no-store' // Disable caching for fresh data
})
const users = result.data || []
const activeUsers = users.filter(user => user.status === 'active')
return (
<div>
<h1>Users ({users.length} total, {activeUsers.length} active)</h1>
<p>Updated: {new Date().toLocaleString()}</p>
<div>
{users.map(user => (
<div key={user.id} className={user.status === 'active' ? 'active' : 'inactive'}>
<h2>{user.name}</h2>
<p>{user.email}</p>
<span>Status: {user.status}</span>
</div>
))}
</div>
</div>
)
}
Search with SSR
typescript
// app/search/page.tsx
import { serverClient } from '../../lib/client'
import { SearchForm } from './SearchForm'
interface SearchPageProps {
searchParams: { q?: string; type?: string }
}
export default async function SearchPage({ searchParams }: SearchPageProps) {
const { q: query, type = 'posts' } = searchParams
let results = []
if (query) {
if (type === 'posts') {
const result = await serverClient
.from('posts')
.select('id', 'title', 'content', 'authorName')
.or(`title.ilike.%${query}%,content.ilike.%${query}%`)
.limit(20)
.execute({ cache: 'no-store' })
results = result.data || []
} else if (type === 'users') {
const result = await serverClient
.from('users')
.select('id', 'name', 'email')
.ilike('name', `%${query}%`)
.limit(20)
.execute({ cache: 'no-store' })
results = result.data || []
}
}
return (
<div>
<h1>Search</h1>
<SearchForm initialQuery={query} initialType={type} />
{query && (
<div>
<h2>Results for "{query}" in {type}</h2>
<p>Found {results.length} results</p>
{type === 'posts' && (
<div>
{results.map((post: any) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>{post.content.substring(0, 200)}...</p>
<small>By {post.authorName}</small>
</article>
))}
</div>
)}
{type === 'users' && (
<div>
{results.map((user: any) => (
<div key={user.id}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
)}
</div>
)}
</div>
)
}
// app/search/SearchForm.tsx
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useState, useTransition } from 'react'
interface SearchFormProps {
initialQuery?: string
initialType?: string
}
export function SearchForm({ initialQuery = '', initialType = 'posts' }: SearchFormProps) {
const router = useRouter()
const [query, setQuery] = useState(initialQuery)
const [type, setType] = useState(initialType)
const [isPending, startTransition] = useTransition()
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
startTransition(() => {
const params = new URLSearchParams()
if (query) params.set('q', query)
if (type) params.set('type', type)
router.push(`/search?${params.toString()}`)
})
}
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
disabled={isPending}
/>
<select
value={type}
onChange={(e) => setType(e.target.value)}
disabled={isPending}
>
<option value="posts">Posts</option>
<option value="users">Users</option>
</select>
<button type="submit" disabled={isPending}>
{isPending ? 'Searching...' : 'Search'}
</button>
</form>
)
}
Real-time SSR with Subscriptions
Live Data Updates
typescript
// app/live-feed/page.tsx
import { serverClient } from '../../lib/client'
import { LiveFeedClient } from './LiveFeedClient'
export default async function LiveFeedPage() {
// Get initial data server-side
const result = await serverClient
.from('posts')
.select('id', 'title', 'content', 'authorName', 'createdAt')
.order('createdAt', { ascending: false })
.limit(10)
.execute({ cache: 'no-store' })
const initialPosts = result.data || []
return (
<div>
<h1>Live Feed</h1>
<LiveFeedClient initialPosts={initialPosts} />
</div>
)
}
// app/live-feed/LiveFeedClient.tsx
'use client'
import { useEffect, useState } from 'react'
import { clientClient } from '../../lib/client'
interface Post {
id: number
title: string
content: string
authorName: string
createdAt: string
}
export function LiveFeedClient({ initialPosts }: { initialPosts: Post[] }) {
const [posts, setPosts] = useState(initialPosts)
useEffect(() => {
// Subscribe to new posts
const subscription = clientClient
.from('posts')
.on('INSERT', (payload) => {
setPosts(current => [payload.new, ...current])
})
.subscribe()
return () => {
subscription.unsubscribe()
}
}, [])
return (
<div>
<p>Posts update in real-time</p>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
<small>
By {post.authorName} at {new Date(post.createdAt).toLocaleString()}
</small>
</article>
))}
</div>
)
}
Performance Optimization
Request Deduplication
typescript
// lib/server-cache.ts
import { unstable_cache } from 'next/cache'
import { serverClient } from './client'
export const getCachedUser = unstable_cache(
async (userId: number) => {
const result = await serverClient
.from('users')
.select('*')
.eq('id', userId)
.single()
.execute()
return result.data
},
['user'],
{
revalidate: 300, // 5 minutes
tags: ['users']
}
)
export const getCachedPosts = unstable_cache(
async (authorId?: number) => {
let query = serverClient
.from('posts')
.select('*')
.order('createdAt', { ascending: false })
if (authorId) {
query = query.eq('authorId', authorId)
}
const result = await query.limit(20).execute()
return result.data || []
},
['posts'],
{
revalidate: 60, // 1 minute
tags: ['posts']
}
)
Streaming SSR
typescript
// app/dashboard/isLoading.tsx
export default function Loading() {
return (
<div>
<h1>Dashboard</h1>
<div>Loading your data...</div>
<div className="skeleton">
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
<div className="skeleton-line"></div>
</div>
</div>
)
}
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './UserStats'
import { RecentActivity } from './RecentActivity'
import { QuickActions } from './QuickActions'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
<Suspense fallback={<div>Loading stats...</div>}>
<UserStats />
</Suspense>
<Suspense fallback={<div>Loading activity...</div>}>
<RecentActivity />
</Suspense>
<QuickActions /> {/* No suspense - renders immediately */}
</div>
)
}
Error Handling
Server Error Boundaries
typescript
// app/error.tsx
'use client'
import { useEffect } from 'react'
import { PostgRESTError } from '@webcoded/pgrestify'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('SSR Error:', error)
}, [error])
if (error instanceof PostgRESTError) {
return (
<div className="error-boundary">
<h2>Database Error</h2>
<p>Status: {error.statusCode}</p>
<p>Message: {error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
return (
<div className="error-boundary">
<h2>Something went wrong!</h2>
<details>
<summary>Error details</summary>
<pre>{error.message}</pre>
</details>
<button onClick={reset}>Try again</button>
</div>
)
}
// app/not-found.tsx
import Link from 'next/link'
export default function NotFound() {
return (
<div className="not-found">
<h2>Not Found</h2>
<p>Could not find the requested resource</p>
<Link href="/">Return Home</Link>
</div>
)
}
SEO and Metadata
Dynamic Metadata Generation
typescript
// app/posts/[id]/page.tsx
import { Metadata } from 'next'
import { serverClient } from '../../../lib/client'
import { notFound } from 'next/navigation'
interface Post {
id: number
title: string
content: string
excerpt: string
authorName: string
createdAt: string
tags: string[]
}
export async function generateMetadata(
{ params }: { params: { id: string } }
): Promise<Metadata> {
const result = await serverClient
.from('posts')
.select('title', 'excerpt', 'authorName', 'tags')
.eq('id', params.id)
.single()
.execute()
const post = result.data
if (!post) {
return {
title: 'Post Not Found'
}
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.authorName }],
keywords: post.tags,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
authors: [post.authorName]
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt
}
}
}
export default async function PostPage({ params }: { params: { id: string } }) {
const result = await serverClient
.from<Post>('posts')
.select('*')
.eq('id', params.id)
.single()
.execute({ cache: 'no-store' })
if (!result.data) {
notFound()
}
const post = result.data
return (
<article>
<h1>{post.title}</h1>
<div>
<p>By {post.authorName}</p>
<p>Published: {new Date(post.createdAt).toLocaleDateString()}</p>
<div>Tags: {post.tags.join(', ')}</div>
</div>
<div>
{post.content.split('\n').map((paragraph, index) => (
<p key={index}>{paragraph}</p>
))}
</div>
</article>
)
}
Best Practices
1. Data Fetching Strategy
typescript
// Use SSR for:
// - User-specific content
// - Real-time data
// - Search results
// - Authentication-dependent pages
// Use SSG with ISR for:
// - Public content
// - Content that changes occasionally
// - SEO-critical pages
2. Performance Considerations
typescript
// Optimize database queries
const optimizedQuery = serverClient
.from('posts')
.select('id', 'title', 'excerpt') // Only needed fields
.limit(10) // Pagination
.order('createdAt', { ascending: false })
.execute({
cache: 'no-store', // Fresh data for SSR
next: {
tags: ['posts'] // For targeted revalidation
}
})
3. Security Best Practices
typescript
// Validate server-side inputs
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.params!
// Validate input
if (!id || isNaN(Number(id))) {
return { notFound: true }
}
// Use parameterized queries (automatically handled by PGRestify)
const result = await serverClient
.from('posts')
.select('*')
.eq('id', Number(id))
.single()
.execute()
return {
props: {
post: result.data
}
}
}