Skip to content

Next.js Integration Examples

Complete examples for integrating PGRestify with Next.js applications using both App Router and Pages Router.

Installation & Setup

bash
npm install @webcoded/pgrestify

App Router Examples (Next.js 13+)

Client Configuration

typescript
// lib/pgrestify.ts
import { createClient } from '@webcoded/pgrestify';

export const client = createClient({
  url: process.env.NEXT_PUBLIC_POSTGREST_URL || 'http://localhost:3000',
  auth: {
    persistSession: true,
    autoRefreshToken: true
  },
  cache: {
    enabled: true,
    ttl: 300000 // 5 minutes
  }
});

Server Components with Repository Pattern

tsx
// app/users/page.tsx (Server Component)
import { client } from '@/lib/pgrestify';

interface User {
  id: number;
  name: string;
  email: string;
  active: boolean;
  created_at: string;
}

export default async function UsersPage() {
  // Server-side data fetching with repository pattern
  const userRepo = client.getRepository<User>('users');
  
  const users = await userRepo
    .createQueryBuilder()
    .where('active = :active', { active: true })
    .orderBy('created_at', 'DESC')
    .limit(50)
    .getMany();

  return (
    <div>
      <h1>Users</h1>
      <div className="grid gap-4">
        {users.map(user => (
          <div key={user.id} className="p-4 border rounded">
            <h3>{user.name}</h3>
            <p>{user.email}</p>
            <small>Joined: {new Date(user.created_at).toLocaleDateString()}</small>
          </div>
        ))}
      </div>
    </div>
  );
}

Server Components with PostgREST Syntax

tsx
// app/posts/page.tsx (Server Component)
import { client } from '@/lib/pgrestify';

interface Post {
  id: number;
  title: string;
  content: string;
  published: boolean;
  author: {
    name: string;
    email: string;
  };
}

export default async function PostsPage() {
  // Server-side data fetching with PostgREST syntax
  const { data: posts, error } = await client
    .from('posts')
    .select(`
      id,
      title,
      content,
      published,
      author:users!posts_author_id_fkey(name, email)
    `)
    .eq('published', true)
    .order('created_at', { ascending: false })
    .limit(20)
    .execute();

  if (error) {
    console.error('Failed to fetch posts:', error);
    return <div>Failed to load posts</div>;
  }

  return (
    <div>
      <h1>Latest Posts</h1>
      <div className="space-y-6">
        {posts?.map(post => (
          <article key={post.id} className="p-6 border rounded-lg">
            <h2 className="text-2xl font-bold mb-2">{post.title}</h2>
            <p className="text-gray-600 mb-4">{post.content}</p>
            <div className="text-sm text-gray-500">
              By {post.author.name}
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}

Client Components with Hooks

tsx
// app/components/UserProfile.tsx (Client Component)
'use client';

import { useState } from 'react';
import { useQuery, useMutation } from '@webcoded/pgrestify/react';
import { client } from '@/lib/pgrestify';

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

export default function UserProfile({ userId }: { userId: number }) {
  const [isEditing, setIsEditing] = useState(false);

  // Fetch user data
  const { 
    data: user, 
    loading, 
    error,
    refetch 
  } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const userRepo = client.getRepository<User>('users');
      return await userRepo.findOne({ id: userId });
    },
    enabled: !!userId
  });

  // Update user mutation
  const { 
    mutate: updateUser, 
    loading: updating 
  } = useMutation({
    mutationFn: async (updates: Partial<User>) => {
      const userRepo = client.getRepository<User>('users');
      return await userRepo.update({ id: userId }, updates);
    },
    onSuccess: () => {
      setIsEditing(false);
      refetch();
    }
  });

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  if (!user) return <div>User not found</div>;

  return (
    <div className="p-6 border rounded-lg">
      {isEditing ? (
        <EditForm 
          user={user} 
          onSave={updateUser}
          onCancel={() => setIsEditing(false)}
          loading={updating}
        />
      ) : (
        <DisplayMode 
          user={user} 
          onEdit={() => setIsEditing(true)} 
        />
      )}
    </div>
  );
}

function DisplayMode({ user, onEdit }: { user: User; onEdit: () => void }) {
  return (
    <div>
      <h2 className="text-xl font-bold">{user.name}</h2>
      <p className="text-gray-600">{user.email}</p>
      {user.bio && <p className="mt-2">{user.bio}</p>}
      <button 
        onClick={onEdit}
        className="mt-4 px-4 py-2 bg-blue-500 text-white rounded"
      >
        Edit Profile
      </button>
    </div>
  );
}

function EditForm({ 
  user, 
  onSave, 
  onCancel, 
  loading 
}: { 
  user: User; 
  onSave: (updates: Partial<User>) => void;
  onCancel: () => void;
  loading: boolean;
}) {
  const [formData, setFormData] = useState({
    name: user.name,
    bio: user.bio || ''
  });

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    onSave(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className="space-y-4">
        <div>
          <label className="block text-sm font-medium">Name</label>
          <input
            type="text"
            value={formData.name}
            onChange={(e) => setFormData({ ...formData, name: e.target.value })}
            className="mt-1 block w-full border rounded px-3 py-2"
          />
        </div>
        <div>
          <label className="block text-sm font-medium">Bio</label>
          <textarea
            value={formData.bio}
            onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
            className="mt-1 block w-full border rounded px-3 py-2"
            rows={3}
          />
        </div>
        <div className="flex gap-2">
          <button 
            type="submit" 
            disabled={loading}
            className="px-4 py-2 bg-green-500 text-white rounded disabled:opacity-50"
          >
            {loading ? 'Saving...' : 'Save'}
          </button>
          <button 
            type="button" 
            onClick={onCancel}
            className="px-4 py-2 bg-gray-500 text-white rounded"
          >
            Cancel
          </button>
        </div>
      </div>
    </form>
  );
}

API Routes

typescript
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { client } from '@/lib/pgrestify';

// GET /api/users
export async function GET(request: NextRequest) {
  try {
    const { searchParams } = new URL(request.url);
    const page = parseInt(searchParams.get('page') || '1');
    const limit = parseInt(searchParams.get('limit') || '10');
    const search = searchParams.get('search');

    const userRepo = client.getRepository('users');
    let query = userRepo
      .createQueryBuilder()
      .where('active = :active', { active: true });

    if (search) {
      query = query.andWhere(
        '(name ILIKE :search OR email ILIKE :search)',
        { search: `%${search}%` }
      );
    }

    const [users, total] = await query
      .orderBy('created_at', 'DESC')
      .limit(limit)
      .offset((page - 1) * limit)
      .getManyAndCount();

    return NextResponse.json({
      users,
      pagination: {
        page,
        limit,
        total,
        totalPages: Math.ceil(total / limit)
      }
    });
  } catch (error) {
    console.error('Failed to fetch users:', error);
    return NextResponse.json(
      { error: 'Failed to fetch users' },
      { status: 500 }
    );
  }
}

// POST /api/users
export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const { name, email, bio } = body;

    // Validation
    if (!name || !email) {
      return NextResponse.json(
        { error: 'Name and email are required' },
        { status: 400 }
      );
    }

    const userRepo = client.getRepository('users');
    
    // Check if email already exists
    const existingUser = await userRepo.findOne({ email });
    if (existingUser) {
      return NextResponse.json(
        { error: 'Email already exists' },
        { status: 409 }
      );
    }

    const newUser = await userRepo.save({
      name,
      email,
      bio,
      active: true,
      created_at: new Date().toISOString()
    });

    return NextResponse.json(newUser, { status: 201 });
  } catch (error) {
    console.error('Failed to create user:', error);
    return NextResponse.json(
      { error: 'Failed to create user' },
      { status: 500 }
    );
  }
}

Dynamic API Routes

typescript
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { client } from '@/lib/pgrestify';

// GET /api/users/[id]
export async function GET(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const userId = parseInt(params.id);
    
    if (isNaN(userId)) {
      return NextResponse.json(
        { error: 'Invalid user ID' },
        { status: 400 }
      );
    }

    const userRepo = client.getRepository('users');
    const user = await userRepo.findOne({ id: userId });

    if (!user) {
      return NextResponse.json(
        { error: 'User not found' },
        { status: 404 }
      );
    }

    return NextResponse.json(user);
  } catch (error) {
    console.error('Failed to fetch user:', error);
    return NextResponse.json(
      { error: 'Failed to fetch user' },
      { status: 500 }
    );
  }
}

// PATCH /api/users/[id]
export async function PATCH(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const userId = parseInt(params.id);
    const updates = await request.json();

    const userRepo = client.getRepository('users');
    const updatedUser = await userRepo.update(
      { id: userId },
      { ...updates, updated_at: new Date().toISOString() }
    );

    return NextResponse.json(updatedUser);
  } catch (error) {
    console.error('Failed to update user:', error);
    return NextResponse.json(
      { error: 'Failed to update user' },
      { status: 500 }
    );
  }
}

// DELETE /api/users/[id]
export async function DELETE(
  request: NextRequest,
  { params }: { params: { id: string } }
) {
  try {
    const userId = parseInt(params.id);

    const userRepo = client.getRepository('users');
    await userRepo.delete({ id: userId });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Failed to delete user:', error);
    return NextResponse.json(
      { error: 'Failed to delete user' },
      { status: 500 }
    );
  }
}

Pages Router Examples (Next.js 12 and below)

SSG (Static Site Generation)

tsx
// pages/posts/index.tsx
import { GetStaticProps } from 'next';
import { client } from '@/lib/pgrestify';

interface Post {
  id: number;
  title: string;
  content: string;
  published_at: string;
  author: {
    name: string;
  };
}

interface PostsPageProps {
  posts: Post[];
}

export default function PostsPage({ posts }: PostsPageProps) {
  return (
    <div>
      <h1>All Posts</h1>
      <div className="space-y-6">
        {posts.map(post => (
          <article key={post.id} className="p-6 border rounded">
            <h2>{post.title}</h2>
            <p>{post.content}</p>
            <small>By {post.author.name}</small>
          </article>
        ))}
      </div>
    </div>
  );
}

export const getStaticProps: GetStaticProps = async () => {
  try {
    // Fetch posts at build time
    const { data: posts } = await client
      .from('posts')
      .select(`
        id,
        title,
        content,
        published_at,
        author:users!posts_author_id_fkey(name)
      `)
      .eq('published', true)
      .order('published_at', { ascending: false })
      .execute();

    return {
      props: {
        posts: posts || []
      },
      revalidate: 3600 // Revalidate every hour
    };
  } catch (error) {
    console.error('Failed to fetch posts:', error);
    return {
      props: {
        posts: []
      }
    };
  }
};

SSR (Server-Side Rendering)

tsx
// pages/users/[id].tsx
import { GetServerSideProps } from 'next';
import { client } from '@/lib/pgrestify';

interface User {
  id: number;
  name: string;
  email: string;
  bio?: string;
  posts: Array<{
    id: number;
    title: string;
    published: boolean;
  }>;
}

interface UserPageProps {
  user: User | null;
  error?: string;
}

export default function UserPage({ user, error }: UserPageProps) {
  if (error) {
    return <div>Error: {error}</div>;
  }

  if (!user) {
    return <div>User not found</div>;
  }

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
      {user.bio && <p>{user.bio}</p>}
      
      <h2>Posts</h2>
      <ul>
        {user.posts.map(post => (
          <li key={post.id}>
            {post.title} {post.published ? '✅' : '❌'}
          </li>
        ))}
      </ul>
    </div>
  );
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  try {
    const { id } = context.params!;
    const userId = parseInt(id as string);

    if (isNaN(userId)) {
      return {
        props: {
          user: null,
          error: 'Invalid user ID'
        }
      };
    }

    // Fetch user with posts using repository pattern
    const userRepo = client.getRepository<User>('users');
    const user = await userRepo
      .createQueryBuilder()
      .leftJoinAndSelect('posts', 'post')
      .where('id = :id', { id: userId })
      .getOne();

    return {
      props: {
        user: user || null
      }
    };
  } catch (error) {
    console.error('Failed to fetch user:', error);
    return {
      props: {
        user: null,
        error: 'Failed to fetch user'
      }
    };
  }
};

API Routes (Pages Router)

typescript
// pages/api/posts.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { client } from '@/lib/pgrestify';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (req.method === 'GET') {
    try {
      const { published, author_id, page = 1, limit = 10 } = req.query;

      const postRepo = client.getRepository('posts');
      let query = postRepo.createQueryBuilder();

      if (published !== undefined) {
        query = query.where('published = :published', { 
          published: published === 'true' 
        });
      }

      if (author_id) {
        query = query.andWhere('author_id = :authorId', { 
          authorId: parseInt(author_id as string) 
        });
      }

      const [posts, total] = await query
        .leftJoinAndSelect('users', 'author')
        .orderBy('created_at', 'DESC')
        .limit(parseInt(limit as string))
        .offset((parseInt(page as string) - 1) * parseInt(limit as string))
        .getManyAndCount();

      res.status(200).json({
        posts,
        pagination: {
          page: parseInt(page as string),
          limit: parseInt(limit as string),
          total,
          totalPages: Math.ceil(total / parseInt(limit as string))
        }
      });
    } catch (error) {
      console.error('Failed to fetch posts:', error);
      res.status(500).json({ error: 'Failed to fetch posts' });
    }
  } else if (req.method === 'POST') {
    try {
      const { title, content, author_id } = req.body;

      if (!title || !content || !author_id) {
        return res.status(400).json({ 
          error: 'Title, content, and author_id are required' 
        });
      }

      const postRepo = client.getRepository('posts');
      const newPost = await postRepo.save({
        title,
        content,
        author_id,
        published: false,
        created_at: new Date().toISOString()
      });

      res.status(201).json(newPost);
    } catch (error) {
      console.error('Failed to create post:', error);
      res.status(500).json({ error: 'Failed to create post' });
    }
  } else {
    res.setHeader('Allow', ['GET', 'POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}

Advanced Patterns

Real-time Updates with Subscriptions

tsx
// components/LivePostFeed.tsx
'use client';

import { useEffect, useState } from 'react';
import { client } from '@/lib/pgrestify';

interface Post {
  id: number;
  title: string;
  content: string;
  author: { name: string };
}

export default function LivePostFeed() {
  const [posts, setPosts] = useState<Post[]>([]);

  useEffect(() => {
    // Subscribe to new posts
    const subscription = client
      .from('posts')
      .on('INSERT', (payload) => {
        console.log('New post:', payload.new);
        setPosts(prev => [payload.new as Post, ...prev]);
      })
      .subscribe();

    return () => {
      subscription.unsubscribe();
    };
  }, []);

  return (
    <div>
      <h2>Live Post Feed</h2>
      <div className="space-y-4">
        {posts.map(post => (
          <div key={post.id} className="p-4 border rounded">
            <h3>{post.title}</h3>
            <p>{post.content}</p>
            <small>By {post.author.name}</small>
          </div>
        ))}
      </div>
    </div>
  );
}

Authentication with Next.js

tsx
// hooks/useAuth.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';
import { client } from '@/lib/pgrestify';

interface AuthContextType {
  user: any | null;
  loading: boolean;
  signIn: (email: string, password: string) => Promise<void>;
  signOut: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Check for existing session
    client.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null);
      setLoading(false);
    });

    // Listen for auth changes
    const { data: { subscription } } = client.auth.onAuthStateChange(
      (_event, session) => {
        setUser(session?.user ?? null);
        setLoading(false);
      }
    );

    return () => subscription.unsubscribe();
  }, []);

  const signIn = async (email: string, password: string) => {
    const { data, error } = await client.auth.signInWithPassword({
      email,
      password
    });

    if (error) throw error;
    setUser(data.user);
  };

  const signOut = async () => {
    const { error } = await client.auth.signOut();
    if (error) throw error;
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, loading, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  const context = useContext(AuthContext);
  if (context === undefined) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return context;
}

Environment Configuration

bash
# .env.local
NEXT_PUBLIC_POSTGREST_URL=http://localhost:3000
POSTGREST_ANON_KEY=your-anon-key
POSTGREST_SERVICE_KEY=your-service-key

Best Practices

1. Type Safety

typescript
// Define your database schema types
interface Database {
  users: User;
  posts: Post;
  comments: Comment;
}

// Use typed client
const typedClient = createClient<Database>({
  url: process.env.NEXT_PUBLIC_POSTGREST_URL!
});

2. Error Boundaries

tsx
// components/ErrorBoundary.tsx
'use client';

import { Component, ReactNode } from 'react';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
}

interface State {
  hasError: boolean;
}

export class ErrorBoundary extends Component<Props, State> {
  constructor(props: Props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(_: Error): State {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback || <div>Something went wrong.</div>;
    }

    return this.props.children;
  }
}

3. Loading States

tsx
// components/LoadingSpinner.tsx
export function LoadingSpinner() {
  return (
    <div className="flex justify-center items-center">
      <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
    </div>
  );
}

This comprehensive guide demonstrates:

  • ✅ Both App Router and Pages Router examples
  • ✅ Server Components and Client Components
  • ✅ SSG, SSR, and ISR patterns
  • ✅ API Routes with both syntaxes
  • ✅ Real-time subscriptions
  • ✅ Authentication integration
  • ✅ Error handling and loading states
  • ✅ TypeScript best practices
  • ✅ Performance optimization techniques

Released under the MIT License.