Skip to content

App Router

Next.js App Router is the modern routing system built on React Server Components, providing powerful features like streaming, concurrent rendering, and advanced caching. PGRestify provides first-class support for App Router with optimized server and client components integration.

Overview

App Router features supported by PGRestify:

  • React Server Components: Direct database access in server components
  • Streaming & Suspense: Progressive page isLoading with fallback UI
  • Parallel Routes: Load multiple route segments simultaneously
  • Intercepting Routes: Intercept and modify route behavior
  • Route Groups: Organize routes without affecting URL structure
  • Server Actions: Type-safe server-side mutations
  • Middleware: Request/response manipulation with edge runtime
  • Advanced Caching: ISR, full-route cache, and data cache

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

app/
├── layout.tsx              # Root layout
├── page.tsx               # Home page
├── isLoading.tsx            # Loading UI
├── error.tsx              # Error UI
├── not-found.tsx          # 404 page
├── global-error.tsx       # Global error boundary

├── (auth)/                # Route group
│   ├── login/
│   │   └── page.tsx
│   └── signup/
│       └── page.tsx

├── dashboard/
│   ├── layout.tsx         # Nested layout
│   ├── page.tsx           # Dashboard home
│   ├── @analytics/        # Parallel route
│   │   └── page.tsx
│   └── users/
│       ├── page.tsx       # Users list
│       └── [id]/
│           └── page.tsx   # User details

└── api/                   # API routes
    ├── auth/
    └── posts/
        └── route.ts

lib/
├── client.ts              # PGRestify client
├── server.ts              # Server utilities
└── types.ts               # TypeScript types

Root Layout Setup

typescript
// app/layout.tsx
import { PGRestifyProvider } from '@webcoded/pgrestify/nextjs';
import { Inter } from 'next/font/google';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'My App',
  description: 'Built with Next.js and PGRestify',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <PGRestifyProvider
          config={{
            url: process.env.NEXT_PUBLIC_POSTGREST_URL!,
            revalidate: {
              tags: ['posts', 'users', 'comments']
            }
          }}
        >
          {children}
        </PGRestifyProvider>
      </body>
    </html>
  );
}

Server Components

Basic Data Fetching

typescript
// app/posts/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';
import Link from 'next/link';

// This page will be cached and regenerated in the background
export const revalidate = 3600; // 1 hour

export default async function PostsPage() {
  const client = createServerClient();
  
  const { data: posts, error } = await client
    .from('posts')
    .select(`
      id,
      title,
      excerpt,
      created_at,
      author:users(id, name, avatar_url),
      category:categories(id, name, color)
    `)
    .eq('published', true)
    .order('created_at', { ascending: false })
    .limit(20);

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

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Latest Posts</h1>
      
      <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">
            <div className="flex items-center gap-2 mb-4">
              <span 
                className="px-2 py-1 rounded text-xs font-medium"
                style={{ backgroundColor: post.category.color }}
              >
                {post.category.name}
              </span>
            </div>
            
            <h2 className="text-xl font-semibold mb-2">
              <Link 
                href={`/posts/${post.id}`}
                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>
    </div>
  );
}

// Generate metadata
export async function generateMetadata() {
  const client = createServerClient();
  const { count } = await client.from('posts').select('*', { count: 'exact', head: true });
  
  return {
    title: `Posts (${count}) - My Blog`,
    description: `Browse our collection of ${count} articles and insights.`
  };
}

Dynamic Routes with Params

typescript
// app/posts/[slug]/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';
import { notFound } from 'next/navigation';
import { Metadata } from 'next';

interface PostPageProps {
  params: { slug: string };
  searchParams: { [key: string]: string | string[] | undefined };
}

export default async function PostPage({ params }: PostPageProps) {
  const client = createServerClient();
  
  const { data: post, error } = await client
    .from('posts')
    .select(`
      *,
      author:users(id, name, bio, avatar_url),
      category:categories(id, name, color),
      comments:comments(
        id,
        content,
        created_at,
        author:users(id, name, avatar_url)
      )
    `)
    .eq('slug', params.slug)
    .eq('published', true)
    .single();

  if (error || !post) {
    notFound();
  }

  return (
    <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 ({post.comments?.length || 0})
        </h2>
        
        <div className="space-y-6">
          {post.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>
  );
}

// Generate metadata for SEO
export async function generateMetadata({ params }: PostPageProps): Promise<Metadata> {
  const client = createServerClient();
  
  const { data: post } = await client
    .from('posts')
    .select('title, excerpt, featured_image, author:users(name)')
    .eq('slug', params.slug)
    .single();

  if (!post) {
    return {
      title: 'Post Not Found'
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    authors: [{ name: post.author.name }],
    openGraph: {
      title: post.title,
      description: post.excerpt,
      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,
    }
  };
}

// Generate static params for ISG
export async function generateStaticParams() {
  const client = createServerClient();
  
  const { data: posts } = await client
    .from('posts')
    .select('slug')
    .eq('published', true)
    .limit(100); // Generate top 100 posts at build time

  return posts?.map(post => ({
    slug: post.slug
  })) || [];
}

Client Components

Interactive Components

typescript
// app/posts/[id]/comments.tsx
'use client';

import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@webcoded/pgrestify/nextjs';
import { useSession } from '@webcoded/pgrestify/nextjs';

interface CommentsProps {
  postId: string;
}

export default function Comments({ postId }: CommentsProps) {
  const [newComment, setNewComment] = useState('');
  const { user, isAuthenticated } = useSession();
  const queryClient = useQueryClient();

  // Query comments
  const { data: comments, isLoading } = useQuery({
    queryKey: ['comments', postId],
    queryFn: ({ client }) => 
      client
        .from('comments')
        .select(`
          id,
          content,
          created_at,
          author:users(id, name, avatar_url)
        `)
        .eq('post_id', postId)
        .order('created_at', { ascending: false })
  });

  // 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: postId,
          author_id: user?.id
        })
        .select(`
          id,
          content,
          created_at,
          author:users(id, name, avatar_url)
        `)
        .single();

      if (error) throw error;
      return data;
    },
    
    onSuccess: (newComment) => {
      // Add to cache optimistically
      queryClient.setQueryData(['comments', postId], (old: any[]) => 
        [newComment, ...(old || [])]
      );
      setNewComment('');
    },
    
    onError: (error) => {
      console.error('Failed to add comment:', error);
    }
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!newComment.trim() || !isAuthenticated) return;
    
    addComment.mutate({ content: newComment });
  };

  if (isLoading) {
    return <div>Loading comments...</div>;
  }

  return (
    <div className="space-y-6">
      {/* Add comment form */}
      {isAuthenticated ? (
        <form onSubmit={handleSubmit} className="space-y-4">
          <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="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
          >
            {addComment.isPending ? 'Adding...' : 'Add Comment'}
          </button>
        </form>
      ) : (
        <div className="text-center py-4">
          <p className="text-gray-600">Please sign in to comment</p>
        </div>
      )}

      {/* Comments list */}
      <div className="space-y-4">
        {comments?.map(comment => (
          <div key={comment.id} className="flex gap-3 p-4 bg-gray-50 rounded-lg">
            <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-1">
                <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>
        ))}
        
        {comments?.length === 0 && (
          <p className="text-center text-gray-500 py-8">
            No comments yet. Be the first to comment!
          </p>
        )}
      </div>
    </div>
  );
}

Streaming & Suspense

Progressive Loading

typescript
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats } from './user-stats';
import { RecentPosts } from './recent-posts';
import { Analytics } from './analytics';

export default function Dashboard() {
  return (
    <div className="space-y-6">
      <h1 className="text-3xl font-bold">Dashboard</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        {/* Fast isLoading component */}
        <Suspense fallback={<div className="h-32 bg-gray-100 animate-pulse rounded"></div>}>
          <UserStats />
        </Suspense>
        
        {/* Slower isLoading component */}
        <Suspense fallback={<div className="h-32 bg-gray-100 animate-pulse rounded"></div>}>
          <RecentPosts />
        </Suspense>
        
        {/* Very slow isLoading component */}
        <Suspense fallback={<div className="h-32 bg-gray-100 animate-pulse rounded"></div>}>
          <Analytics />
        </Suspense>
      </div>
    </div>
  );
}

// app/dashboard/user-stats.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';

export async function UserStats() {
  const client = createServerClient();
  
  const [
    { count: postsCount },
    { count: commentsCount },
    { count: likesCount }
  ] = await Promise.all([
    client.from('posts').select('*', { count: 'exact', head: true }),
    client.from('comments').select('*', { count: 'exact', head: true }),
    client.from('likes').select('*', { count: 'exact', head: true })
  ]);

  return (
    <div className="bg-white p-6 rounded-lg shadow">
      <h3 className="text-lg font-semibold mb-4">Your Stats</h3>
      <div className="space-y-2">
        <div className="flex justify-between">
          <span>Posts:</span>
          <span className="font-medium">{postsCount}</span>
        </div>
        <div className="flex justify-between">
          <span>Comments:</span>
          <span className="font-medium">{commentsCount}</span>
        </div>
        <div className="flex justify-between">
          <span>Likes:</span>
          <span className="font-medium">{likesCount}</span>
        </div>
      </div>
    </div>
  );
}

Server Actions

Form Handling with Server Actions

typescript
// app/posts/create/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';
import { redirect } from 'next/navigation';
import { revalidateTag } from 'next/cache';

async function createPost(formData: FormData) {
  'use server';
  
  const client = createServerClient();
  
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;
  const categoryId = formData.get('category_id') as string;
  
  if (!title || !content) {
    throw new Error('Title and content are required');
  }

  const { data: post, error } = await client
    .from('posts')
    .insert({
      title,
      content,
      category_id: parseInt(categoryId),
      published: true
    })
    .select('id, slug')
    .single();

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

  // Revalidate cached data
  revalidateTag('posts');
  
  // Redirect to the new post
  redirect(`/posts/${post.slug}`);
}

export default async function CreatePostPage() {
  const client = createServerClient();
  
  const { data: categories } = await client
    .from('categories')
    .select('id, name')
    .order('name');

  return (
    <div className="max-w-2xl mx-auto px-4 py-8">
      <h1 className="text-3xl font-bold mb-6">Create New Post</h1>
      
      <form action={createPost} className="space-y-6">
        <div>
          <label htmlFor="title" className="block text-sm font-medium mb-2">
            Title
          </label>
          <input
            type="text"
            id="title"
            name="title"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          />
        </div>
        
        <div>
          <label htmlFor="category_id" className="block text-sm font-medium mb-2">
            Category
          </label>
          <select
            id="category_id"
            name="category_id"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          >
            <option value="">Select a category</option>
            {categories?.map(category => (
              <option key={category.id} value={category.id}>
                {category.name}
              </option>
            ))}
          </select>
        </div>
        
        <div>
          <label htmlFor="content" className="block text-sm font-medium mb-2">
            Content
          </label>
          <textarea
            id="content"
            name="content"
            required
            rows={10}
            className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
          />
        </div>
        
        <button
          type="submit"
          className="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors"
        >
          Create Post
        </button>
      </form>
    </div>
  );
}

Advanced Patterns

Parallel Routes

typescript
// app/dashboard/layout.tsx
export default function DashboardLayout({
  children,
  analytics,
  notifications
}: {
  children: React.ReactNode;
  analytics: React.ReactNode;
  notifications: React.ReactNode;
}) {
  return (
    <div className="grid grid-cols-4 gap-6">
      <div className="col-span-3">{children}</div>
      <div className="space-y-6">
        {analytics}
        {notifications}
      </div>
    </div>
  );
}

// app/dashboard/@analytics/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';

export default async function Analytics() {
  const client = createServerClient();
  
  const { data: stats } = await client
    .rpc('get_dashboard_stats')
    .single();

  return (
    <div className="bg-white p-4 rounded-lg shadow">
      <h3 className="font-semibold mb-2">Analytics</h3>
      <div className="space-y-2 text-sm">
        <div>Page Views: {stats?.page_views}</div>
        <div>Unique Visitors: {stats?.unique_visitors}</div>
      </div>
    </div>
  );
}

// app/dashboard/@notifications/page.tsx
import { createServerClient } from '@webcoded/pgrestify/nextjs';

export default async function Notifications() {
  const client = createServerClient();
  
  const { data: notifications } = await client
    .from('notifications')
    .select('*')
    .eq('read', false)
    .limit(5);

  return (
    <div className="bg-white p-4 rounded-lg shadow">
      <h3 className="font-semibold mb-2">Notifications</h3>
      <div className="space-y-2">
        {notifications?.map(notification => (
          <div key={notification.id} className="text-sm p-2 bg-blue-50 rounded">
            {notification.message}
          </div>
        ))}
      </div>
    </div>
  );
}

Route Handlers

typescript
// app/api/posts/route.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs';
import { NextRequest, NextResponse } from 'next/server';
import { revalidateTag } from 'next/cache';

export async function GET(request: NextRequest) {
  const client = createServerClient();
  const { searchParams } = new URL(request.url);
  
  const category = searchParams.get('category');
  const page = parseInt(searchParams.get('page') || '1');
  const limit = parseInt(searchParams.get('limit') || '10');
  
  let query = client
    .from('posts')
    .select('*')
    .eq('published', true)
    .order('created_at', { ascending: false });
    
  if (category) {
    query = query.eq('category', category);
  }
  
  const { data: posts, error } = await query
    .range((page - 1) * limit, page * limit - 1);
    
  if (error) {
    return NextResponse.json(
      { error: error.message }, 
      { status: 500 }
    );
  }
  
  return NextResponse.json(posts);
}

export async function POST(request: NextRequest) {
  const client = createServerClient();
  
  try {
    const body = await request.json();
    
    const { data: post, error } = await client
      .from('posts')
      .insert(body)
      .select()
      .single();
      
    if (error) {
      return NextResponse.json(
        { error: error.message }, 
        { status: 400 }
      );
    }
    
    revalidateTag('posts');
    
    return NextResponse.json(post, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      { error: 'Invalid JSON body' }, 
      { status: 400 }
    );
  }
}

Performance Optimization

Caching Strategies

typescript
// Automatic cache with tags
export const revalidate = 3600; // Cache for 1 hour
export const dynamic = 'force-static'; // Force static generation

// Manual cache control
import { unstable_cache } from 'next/cache';

const getCachedPosts = unstable_cache(
  async () => {
    const client = createServerClient();
    return client.from('posts').select('*');
  },
  ['posts-cache'],
  { revalidate: 3600, tags: ['posts'] }
);

Edge Runtime

typescript
// app/api/search/route.ts
import { createServerClient } from '@webcoded/pgrestify/nextjs';

export const runtime = 'edge';

export async function GET(request: Request) {
  const client = createServerClient({
    fetch: fetch // Use edge-compatible fetch
  });
  
  const { searchParams } = new URL(request.url);
  const query = searchParams.get('q');
  
  if (!query) {
    return Response.json({ error: 'Query parameter required' }, { status: 400 });
  }
  
  const { data: results } = await client
    .from('posts')
    .select('id, title, excerpt')
    .textSearch('title', query)
    .limit(10);
    
  return Response.json(results);
}

Best Practices

Error Handling

typescript
// app/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div className="text-center py-12">
      <h2 className="text-2xl font-bold mb-4">Something went wrong!</h2>
      <p className="text-gray-600 mb-4">{error.message}</p>
      <button
        onClick={() => reset()}
        className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Try again
      </button>
    </div>
  );
}

// app/not-found.tsx
import Link from 'next/link';

export default function NotFound() {
  return (
    <div className="text-center py-12">
      <h2 className="text-2xl font-bold mb-4">Page Not Found</h2>
      <p className="text-gray-600 mb-4">Could not find the requested resource.</p>
      <Link 
        href="/"
        className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
      >
        Return Home
      </Link>
    </div>
  );
}

TypeScript Integration

typescript
// lib/types.ts
export interface Post {
  id: string;
  title: string;
  content: string;
  excerpt: string;
  slug: string;
  published: boolean;
  created_at: string;
  updated_at: string;
  author: User;
  category: Category;
}

export interface User {
  id: string;
  name: string;
  email: string;
  avatar_url: string;
  bio?: string;
}

export interface Category {
  id: string;
  name: string;
  color: string;
  description?: string;
}

Deployment

Environment Configuration

bash
# .env.local
NEXT_PUBLIC_POSTGREST_URL=http://localhost:3000
POSTGREST_URL=http://localhost:3000
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb

Production Build

bash
npm run build
npm run start

Next Steps

Released under the MIT License.