Skip to content

Pages Router ​

Next.js Pages Router is the traditional routing system that has powered Next.js applications since its inception. While App Router is the modern approach, Pages Router remains stable, well-supported, and perfect for many applications. PGRestify provides full support for Pages Router with optimized data fetching and SSR capabilities.

Overview ​

Pages Router features supported by PGRestify:

  • File-based Routing: Automatic routes based on file structure
  • Server-Side Rendering: getServerSideProps with PGRestify integration
  • Static Site Generation: getStaticProps and getStaticPaths
  • API Routes: Full-featured API endpoints in /pages/api
  • Incremental Static Regeneration: Update static pages after build
  • Client-Side Navigation: Optimized routing with next/router
  • Dynamic Routes: [id].js, [...slug].js, and [[...slug]].js
  • Middleware: Request/response processing

Project Setup ​

Install Dependencies ​

bash
npm install @webcoded/pgrestify next@latest react@latest react-dom@latest
# or
pnpm add @webcoded/pgrestify next@latest react@latest react-dom@latest

Directory Structure ​

pages/
├── _app.tsx               # App wrapper
├── _document.tsx          # HTML document structure
├── index.tsx              # Home page (/)
├── about.tsx              # About page (/about)
│
├── posts/
│   ├── index.tsx          # Posts list (/posts)
│   ├── [slug].tsx         # Post detail (/posts/my-post)
│   └── create.tsx         # Create post (/posts/create)
│
├── users/
│   ├── index.tsx          # Users list (/users)
│   ├── [id].tsx           # User profile (/users/123)
│   └── [...params].tsx    # Catch-all route (/users/a/b/c)
│
└── api/
    ├── auth/
    │   ├── login.ts
    │   └── logout.ts
    ├── posts/
    │   ├── index.ts
    │   └── [id].ts
    └── users.ts

lib/
├── client.ts              # PGRestify client
├── ssr.ts                 # SSR utilities
└── types.ts               # TypeScript types

styles/
├── globals.css            # Global styles
└── Home.module.css        # Component styles

App Component Setup ​

typescript
// pages/_app.tsx
import type { AppProps } from 'next/app';
import { PGRestifyProvider } from '@webcoded/pgrestify/nextjs';
import { createClient } from '@webcoded/pgrestify';
import '../styles/globals.css';

export default function App({ Component, pageProps }: AppProps) {
  const client = createClient({
    url: process.env.NEXT_PUBLIC_POSTGREST_URL!,
    auth: {
      persistSession: true,
      autoRefreshToken: true
    },
    cache: {
      enabled: true,
      staleTime: 60 * 1000, // 1 minute
      gcTime: 5 * 60 * 1000, // 5 minutes
    }
  });

  return (
    <PGRestifyProvider client={client}>
      <Component {...pageProps} />
    </PGRestifyProvider>
  );
}

// pages/_document.tsx
import { Html, Head, Main, NextScript } from 'next/document';

export default function Document() {
  return (
    <Html lang="en">
      <Head>
        <meta name="description" content="Built with Next.js and PGRestify" />
      </Head>
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  );
}

Server-Side Rendering (SSR) ​

getServerSideProps Integration ​

typescript
// pages/posts/index.tsx
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { createGetServerSideProps } from '@webcoded/pgrestify/nextjs';
import Link from 'next/link';
import { useState } from 'react';

interface Post {
  id: string;
  title: string;
  excerpt: string;
  slug: string;
  created_at: string;
  author: {
    id: string;
    name: string;
    avatar_url: string;
  };
  category: {
    id: string;
    name: string;
    color: string;
  };
}

interface PostsPageProps {
  posts: Post[];
  totalCount: number;
  currentPage: number;
  totalPages: number;
}

export default function PostsPage({ 
  posts, 
  totalCount, 
  currentPage, 
  totalPages 
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const [searchTerm, setSearchTerm] = useState('');

  return (
    <div className="container mx-auto px-4 py-8">
      <div className="flex justify-between items-center mb-8">
        <h1 className="text-3xl font-bold">Posts ({totalCount})</h1>
        <Link 
          href="/posts/create"
          className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
        >
          Create Post
        </Link>
      </div>

      {/* Search */}
      <div className="mb-6">
        <input
          type="text"
          placeholder="Search posts..."
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          className="w-full p-3 border rounded-lg"
        />
      </div>

      {/* Posts grid */}
      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts
          .filter(post => 
            post.title.toLowerCase().includes(searchTerm.toLowerCase())
          )
          .map(post => (
            <article key={post.id} className="bg-white rounded-lg shadow-md 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>
              </div>
              
              <h2 className="text-xl font-semibold mb-2">
                <Link 
                  href={`/posts/${post.slug}`}
                  className="hover:text-blue-600 transition-colors"
                >
                  {post.title}
                </Link>
              </h2>
              
              <p className="text-gray-600 mb-4">{post.excerpt}</p>
              
              <div className="flex items-center gap-2 text-sm text-gray-500">
                <img 
                  src={post.author.avatar_url} 
                  alt={post.author.name}
                  className="w-6 h-6 rounded-full"
                />
                <span>{post.author.name}</span>
                <span>•</span>
                <time>{new Date(post.created_at).toLocaleDateString()}</time>
              </div>
            </article>
          ))}
      </div>

      {/* Pagination */}
      {totalPages > 1 && (
        <div className="flex justify-center gap-2 mt-8">
          {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
            <Link
              key={page}
              href={`/posts?page=${page}`}
              className={`px-3 py-2 rounded ${
                page === currentPage 
                  ? 'bg-blue-600 text-white' 
                  : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
              }`}
            >
              {page}
            </Link>
          ))}
        </div>
      )}
    </div>
  );
}

// Server-side props with PGRestify
export const getServerSideProps: GetServerSideProps<PostsPageProps> = createGetServerSideProps(
  async ({ client, query }) => {
    const page = parseInt(query.page as string || '1');
    const limit = 12;
    const offset = (page - 1) * limit;

    // Get posts with pagination
    const { data: posts, error } = await 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 })
      .range(offset, offset + limit - 1);

    if (error) {
      throw new Error(`Failed to fetch posts: ${error.message}`);
    }

    // Get total count
    const { count: totalCount } = await client
      .from('posts')
      .select('*', { count: 'exact', head: true })
      .eq('published', true);

    const totalPages = Math.ceil(totalCount / limit);

    return {
      props: {
        posts: posts || [],
        totalCount: totalCount || 0,
        currentPage: page,
        totalPages
      }
    };
  }
);

Dynamic Routes with getServerSideProps ​

typescript
// pages/posts/[slug].tsx
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { createGetServerSideProps } from '@webcoded/pgrestify/nextjs';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@webcoded/pgrestify/react';

interface PostPageProps {
  post: {
    id: string;
    title: string;
    content: string;
    excerpt: string;
    slug: string;
    featured_image?: string;
    created_at: string;
    reading_time: number;
    author: {
      id: string;
      name: string;
      bio: string;
      avatar_url: string;
    };
    category: {
      id: string;
      name: string;
      color: string;
    };
  };
  initialComments: Comment[];
}

export default function PostPage({ 
  post, 
  initialComments 
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
  const router = useRouter();
  const [newComment, setNewComment] = useState('');
  const queryClient = useQueryClient();

  // Client-side comments query (hydrates with initial data)
  const { data: comments, isLoading } = useQuery({
    queryKey: ['comments', post.id],
    queryFn: ({ client }) => 
      client
        .from('comments')
        .select(`
          id,
          content,
          created_at,
          author:users(id, name, avatar_url)
        `)
        .eq('post_id', post.id)
        .order('created_at', { ascending: false }),
    initialData: initialComments
  });

  // Add comment mutation
  const addComment = useMutation({
    mutationFn: async ({ client, content }: { client: any; content: string }) => {
      const { data, error } = await client
        .from('comments')
        .insert({
          content,
          post_id: post.id,
          // author_id would come from auth context
        })
        .select(`
          id,
          content,
          created_at,
          author:users(id, name, avatar_url)
        `)
        .single();

      if (error) throw error;
      return data;
    },
    
    onSuccess: (newComment) => {
      queryClient.setQueryData(['comments', post.id], (old: any[]) => 
        [newComment, ...(old || [])]
      );
      setNewComment('');
    }
  });

  if (router.isFallback) {
    return <div>Loading...</div>;
  }

  return (
    <>
      <Head>
        <title>{post.title}</title>
        <meta name="description" content={post.excerpt} />
        <meta property="og:title" content={post.title} />
        <meta property="og:description" content={post.excerpt} />
        {post.featured_image && (
          <meta property="og:image" content={post.featured_image} />
        )}
        <meta property="og:type" content="article" />
      </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: post.category.color }}
            >
              {post.category.name}
            </span>
          </div>
          
          <h1 className="text-4xl font-bold mb-4">{post.title}</h1>
          
          <div className="flex items-center gap-4 text-gray-600">
            <div className="flex items-center gap-2">
              <img 
                src={post.author.avatar_url} 
                alt={post.author.name}
                className="w-10 h-10 rounded-full"
              />
              <div>
                <div className="font-medium">{post.author.name}</div>
                <div className="text-sm">{post.author.bio}</div>
              </div>
            </div>
            
            <div className="text-sm">
              <time>{new Date(post.created_at).toLocaleDateString()}</time>
              <span className="mx-2">•</span>
              <span>{post.reading_time} min read</span>
            </div>
          </div>
        </header>

        {/* Featured image */}
        {post.featured_image && (
          <img 
            src={post.featured_image} 
            alt={post.title}
            className="w-full h-64 object-cover rounded-lg mb-8"
          />
        )}

        {/* Article content */}
        <div 
          className="prose prose-lg max-w-none mb-12"
          dangerouslySetInnerHTML={{ __html: post.content }}
        />

        {/* Comments section */}
        <section className="border-t pt-8">
          <h2 className="text-2xl font-bold mb-6">
            Comments ({comments?.length || 0})
          </h2>
          
          {/* Add comment form */}
          <form 
            onSubmit={(e) => {
              e.preventDefault();
              if (newComment.trim()) {
                addComment.mutate({ content: newComment });
              }
            }}
            className="mb-8"
          >
            <textarea
              value={newComment}
              onChange={(e) => setNewComment(e.target.value)}
              placeholder="Write a comment..."
              className="w-full p-3 border rounded-lg resize-none"
              rows={3}
            />
            <button
              type="submit"
              disabled={!newComment.trim() || addComment.isPending}
              className="mt-2 px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
            >
              {addComment.isPending ? 'Adding...' : 'Add Comment'}
            </button>
          </form>
          
          {/* Comments list */}
          <div className="space-y-6">
            {comments?.map(comment => (
              <div key={comment.id} className="flex gap-4">
                <img 
                  src={comment.author.avatar_url} 
                  alt={comment.author.name}
                  className="w-10 h-10 rounded-full flex-shrink-0"
                />
                <div className="flex-1">
                  <div className="flex items-center gap-2 mb-2">
                    <span className="font-medium">{comment.author.name}</span>
                    <time className="text-sm text-gray-500">
                      {new Date(comment.created_at).toLocaleDateString()}
                    </time>
                  </div>
                  <p className="text-gray-700">{comment.content}</p>
                </div>
              </div>
            ))}
          </div>
        </section>
      </article>
    </>
  );
}

export const getServerSideProps: GetServerSideProps = createGetServerSideProps(
  async ({ client, params }) => {
    const slug = params?.slug as string;

    // Get post data
    const { data: post, error } = await client
      .from('posts')
      .select(`
        id,
        title,
        content,
        excerpt,
        slug,
        featured_image,
        created_at,
        reading_time,
        author:users(id, name, bio, avatar_url),
        category:categories(id, name, color)
      `)
      .eq('slug', slug)
      .eq('published', true)
      .single();

    if (error || !post) {
      return {
        notFound: true
      };
    }

    // Get initial comments
    const { data: comments } = await client
      .from('comments')
      .select(`
        id,
        content,
        created_at,
        author:users(id, name, avatar_url)
      `)
      .eq('post_id', post.id)
      .order('created_at', { ascending: false })
      .limit(20);

    return {
      props: {
        post,
        initialComments: comments || []
      }
    };
  }
);

Static Site Generation (SSG) ​

getStaticProps with ISR ​

typescript
// pages/categories/[slug].tsx
import { GetStaticProps, GetStaticPaths, InferGetStaticPropsType } from 'next';
import { createGetStaticProps } from '@webcoded/pgrestify/nextjs';

interface CategoryPageProps {
  category: {
    id: string;
    name: string;
    description: string;
    color: string;
    post_count: number;
  };
  posts: Post[];
}

export default function CategoryPage({ 
  category, 
  posts 
}: InferGetStaticPropsType<typeof getStaticProps>) {
  return (
    <div className="container mx-auto px-4 py-8">
      <div className="text-center mb-8">
        <h1 className="text-4xl font-bold mb-4">{category.name}</h1>
        <p className="text-gray-600 text-lg">{category.description}</p>
        <div className="text-sm text-gray-500">
          {category.post_count} posts in this category
        </div>
      </div>

      <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
        {posts.map(post => (
          <article key={post.id} className="bg-white rounded-lg shadow-md p-6">
            <h2 className="text-xl font-semibold mb-2">
              <Link href={`/posts/${post.slug}`}>
                {post.title}
              </Link>
            </h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <time className="text-sm text-gray-500">
              {new Date(post.created_at).toLocaleDateString()}
            </time>
          </article>
        ))}
      </div>
    </div>
  );
}

// Generate static props with ISR
export const getStaticProps: GetStaticProps<CategoryPageProps> = createGetStaticProps(
  async ({ client, params }) => {
    const slug = params?.slug as string;

    // Get category
    const { data: category, error: categoryError } = await client
      .from('categories')
      .select(`
        id,
        name,
        description,
        color,
        slug,
        posts(count)
      `)
      .eq('slug', slug)
      .single();

    if (categoryError || !category) {
      return {
        notFound: true
      };
    }

    // Get posts in this category
    const { data: posts, error: postsError } = await client
      .from('posts')
      .select(`
        id,
        title,
        excerpt,
        slug,
        created_at,
        author:users(name)
      `)
      .eq('category_id', category.id)
      .eq('published', true)
      .order('created_at', { ascending: false })
      .limit(50);

    if (postsError) {
      throw new Error(`Failed to fetch posts: ${postsError.message}`);
    }

    return {
      props: {
        category: {
          ...category,
          post_count: category.posts[0].count
        },
        posts: posts || []
      },
      revalidate: 3600 // Revalidate every hour
    };
  }
);

// Generate static paths
export const getStaticPaths: GetStaticPaths = async () => {
  const client = createServerClient();
  
  const { data: categories } = await client
    .from('categories')
    .select('slug')
    .limit(10); // Only generate top 10 categories at build time

  const paths = categories?.map(category => ({
    params: { slug: category.slug }
  })) || [];

  return {
    paths,
    fallback: 'blocking' // Generate other pages on-demand
  };
};

Client-Side Routing ​

useRouter Hook ​

typescript
// pages/search.tsx
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useQuery } from '@webcoded/pgrestify/react';

export default function SearchPage() {
  const router = useRouter();
  const [searchTerm, setSearchTerm] = useState('');
  
  // Get search term from URL
  useEffect(() => {
    if (router.query.q) {
      setSearchTerm(router.query.q as string);
    }
  }, [router.query.q]);

  // Search query
  const { data: results, isLoading } = useQuery({
    queryKey: ['search', searchTerm],
    queryFn: ({ client }) => 
      client
        .from('posts')
        .select(`
          id,
          title,
          excerpt,
          slug,
          created_at,
          author:users(name)
        `)
        .textSearch('title,content', searchTerm)
        .eq('published', true)
        .limit(20),
    enabled: !!searchTerm
  });

  const handleSearch = (e: React.FormEvent) => {
    e.preventDefault();
    if (searchTerm.trim()) {
      router.push(`/search?q=${encodeURIComponent(searchTerm)}`);
    }
  };

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-8">Search Posts</h1>
      
      <form onSubmit={handleSearch} className="mb-8">
        <div className="flex gap-4">
          <input
            type="text"
            value={searchTerm}
            onChange={(e) => setSearchTerm(e.target.value)}
            placeholder="Search for posts..."
            className="flex-1 p-3 border rounded-lg"
          />
          <button 
            type="submit"
            className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
          >
            Search
          </button>
        </div>
      </form>

      {isLoading && <div>Searching...</div>}
      
      {results && (
        <div>
          <p className="mb-6 text-gray-600">
            Found {results.length} results for "{searchTerm}"
          </p>
          
          <div className="space-y-4">
            {results.map(post => (
              <article key={post.id} className="bg-white p-6 rounded-lg shadow">
                <h2 className="text-xl font-semibold mb-2">
                  <Link href={`/posts/${post.slug}`}>
                    {post.title}
                  </Link>
                </h2>
                <p className="text-gray-600 mb-2">{post.excerpt}</p>
                <div className="text-sm text-gray-500">
                  By {post.author.name} • {new Date(post.created_at).toLocaleDateString()}
                </div>
              </article>
            ))}
          </div>
        </div>
      )}
    </div>
  );
}

API Routes ​

Basic API Routes ​

typescript
// pages/api/posts/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouteHandler } from '@webcoded/pgrestify/nextjs';

export default createRouteHandler({
  GET: async ({ client, req, res }) => {
    const { page = '1', limit = '10', category } = req.query;
    
    const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
    
    let query = client
      .from('posts')
      .select(`
        id,
        title,
        excerpt,
        slug,
        created_at,
        author:users(name),
        category:categories(name)
      `)
      .eq('published', true)
      .order('created_at', { ascending: false })
      .range(offset, offset + parseInt(limit as string) - 1);
    
    if (category) {
      query = query.eq('category', category);
    }
    
    const { data: posts, error } = await query;
    
    if (error) {
      return res.status(500).json({ error: error.message });
    }
    
    res.status(200).json(posts);
  },
  
  POST: async ({ client, req, res }) => {
    const { title, content, category_id, published = false } = req.body;
    
    if (!title || !content) {
      return res.status(400).json({ 
        error: 'Title and content are required' 
      });
    }
    
    const { data: post, error } = await client
      .from('posts')
      .insert({
        title,
        content,
        category_id,
        published,
        // author_id would come from session/auth
      })
      .select()
      .single();
    
    if (error) {
      return res.status(500).json({ error: error.message });
    }
    
    res.status(201).json(post);
  }
});

// pages/api/posts/[id].ts
import { NextApiRequest, NextApiResponse } from 'next';
import { createRouteHandler } from '@webcoded/pgrestify/nextjs';

export default createRouteHandler({
  GET: async ({ client, req, res }) => {
    const { id } = req.query;
    
    const { data: post, error } = await client
      .from('posts')
      .select(`
        *,
        author:users(id, name, bio, avatar_url),
        category:categories(id, name, color)
      `)
      .eq('id', id)
      .single();
    
    if (error || !post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    
    res.status(200).json(post);
  },
  
  PUT: async ({ client, req, res }) => {
    const { id } = req.query;
    const updates = req.body;
    
    const { data: post, error } = await client
      .from('posts')
      .update(updates)
      .eq('id', id)
      .select()
      .single();
    
    if (error) {
      return res.status(500).json({ error: error.message });
    }
    
    res.status(200).json(post);
  },
  
  DELETE: async ({ client, req, res }) => {
    const { id } = req.query;
    
    const { error } = await client
      .from('posts')
      .delete()
      .eq('id', id);
    
    if (error) {
      return res.status(500).json({ error: error.message });
    }
    
    res.status(204).end();
  }
});

Performance Optimizations ​

Optimized Data Fetching ​

typescript
// lib/ssr-helpers.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs';

export async function getPostsWithCache(page: number = 1, limit: number = 10) {
  const client = createServerClient();
  
  // Use client-side caching for frequently accessed data
  const cacheKey = `posts-page-${page}-limit-${limit}`;
  
  const { data: posts, error } = await client
    .from('posts')
    .select(`
      id,
      title,
      excerpt,
      slug,
      created_at,
      author:users(name, avatar_url),
      category:categories(name, color)
    `)
    .eq('published', true)
    .order('created_at', { ascending: false })
    .range((page - 1) * limit, page * limit - 1);
  
  if (error) {
    throw new Error(`Failed to fetch posts: ${error.message}`);
  }
  
  return posts;
}

// pages/posts/index.tsx (optimized)
export const getServerSideProps: GetServerSideProps = createGetServerSideProps(
  async ({ query }) => {
    const page = parseInt(query.page as string || '1');
    
    try {
      const posts = await getPostsWithCache(page);
      
      return {
        props: {
          posts,
          currentPage: page
        }
      };
    } catch (error) {
      return {
        props: {
          posts: [],
          currentPage: page,
          error: error.message
        }
      };
    }
  }
);

Image Optimization ​

typescript
// components/OptimizedImage.tsx
import Image from 'next/image';

interface OptimizedImageProps {
  src: string;
  alt: string;
  width?: number;
  height?: number;
  className?: string;
}

export default function OptimizedImage({ 
  src, 
  alt, 
  width = 400, 
  height = 300, 
  className 
}: OptimizedImageProps) {
  return (
    <Image
      src={src}
      alt={alt}
      width={width}
      height={height}
      className={className}
      placeholder="blur"
      blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQFxQYGBcUFhYaHSUfGhsjHBYWICwgIyYnKSopGR8tMC0oMCUoKSj/2wBDAQcHBwoIChMKChMoGhYaKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCj/wAARCAAIAAoDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAhEAACAQMDBQAAAAAAAAAAAAABAgMABAUGIWGRkqGx0f/EABUBAQEAAAAAAAAAAAAAAAAAAAMF/8QAGhEAAgIDAAAAAAAAAAAAAAAAAAECEgMRkf/aAAwDAQACEQMRAD8AltJagyeH0AthI5xdrLcNM91BF5pX2HaH9bcfaSXWGaRmknyJckliyjqTzSlT54b6bk+h0R//2Q=="
      priority
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
    />
  );
}

Best Practices ​

Error Handling ​

typescript
// lib/error-handler.ts
export function withErrorHandling(handler: any) {
  return async (req: NextApiRequest, res: NextApiResponse) => {
    try {
      return await handler(req, res);
    } catch (error: any) {
      console.error('API Error:', error);
      
      if (error.code === 'PGRST116') {
        return res.status(404).json({ error: 'Resource not found' });
      }
      
      if (error.code === 'PGRST204') {
        return res.status(401).json({ error: 'Unauthorized' });
      }
      
      res.status(500).json({ 
        error: 'Internal server error',
        message: process.env.NODE_ENV === 'development' ? error.message : undefined
      });
    }
  };
}

// pages/api/posts/[id].ts (with error handling)
export default withErrorHandling(createRouteHandler({
  GET: async ({ client, req, res }) => {
    // Route implementation
  }
}));

TypeScript Integration ​

typescript
// types/api.ts
export interface ApiResponse<T> {
  data?: T;
  error?: string;
  message?: string;
}

export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

// lib/api-client.ts
export async function apiRequest<T>(
  endpoint: string,
  options: RequestInit = {}
): Promise<ApiResponse<T>> {
  const response = await fetch(`/api${endpoint}`, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers
    },
    ...options
  });
  
  const data = await response.json();
  
  if (!response.ok) {
    throw new Error(data.error || 'API request failed');
  }
  
  return data;
}

Migration from Pages to App Router ​

When you're ready to migrate to App Router:

typescript
// pages/posts/[slug].tsx → app/posts/[slug]/page.tsx
// getServerSideProps becomes a Server Component
export default async function PostPage({ params }: { params: { slug: string } }) {
  const client = createServerClient();
  
  const { data: post } = await client
    .from('posts')
    .select('*')
    .eq('slug', params.slug)
    .single();
    
  return <div>{/* Component JSX */}</div>;
}

Next Steps ​

Released under the MIT License.