DEV Community

Cover image for Build a Powerful CRUD App with Supabase and Next.js – Full Guide
Suresh Ramani
Suresh Ramani

Posted on • Originally published at techvblogs.com

Build a Powerful CRUD App with Supabase and Next.js – Full Guide

Building modern web applications that handle data efficiently while providing excellent user experiences has never been more accessible. The combination of Supabase and Next.js creates a powerful, scalable foundation for full-stack applications that can grow with your needs. This comprehensive guide will walk you through creating a complete CRUD (Create, Read, Update, Delete) application from scratch, incorporating authentication, real-time features, and production-ready deployment strategies.

What You'll Learn in This Guide

By the end of this tutorial, you'll have built a fully functional web application featuring:

  • User authentication with secure session management
  • Complete CRUD operations for managing data
  • Real-time updates across multiple users
  • Responsive design with modern UI components
  • Production deployment with proper security configurations

This guide assumes basic knowledge of JavaScript and React, but provides detailed explanations for Supabase-specific concepts and Next.js patterns.

Why Supabase + Next.js Is the Perfect Stack for Modern Apps

The combination of Supabase and Next.js addresses common development challenges while maintaining flexibility and performance. Supabase provides a complete backend solution with minimal configuration, while Next.js offers optimized React development with built-in features like server-side rendering and API routes. Together, they eliminate the complexity of backend setup while delivering enterprise-grade capabilities.

This stack is particularly valuable for startups and developers who need to ship quickly without sacrificing scalability or security. The open-source nature of both tools ensures long-term viability and community support.

Understanding the Tech Stack

What Is Supabase? – The Open Source Firebase Alternative

Supabase is an open-source Backend-as-a-Service (BaaS) platform that provides essential backend services including:

  • PostgreSQL Database: Full-featured relational database with JSON support
  • Authentication: Built-in user management with multiple providers
  • Real-time Subscriptions: Live data updates using WebSockets
  • Storage: File upload and management system
  • Edge Functions: Serverless functions for custom logic

Unlike proprietary alternatives, Supabase runs on standard PostgreSQL, ensuring data portability and avoiding vendor lock-in. The platform generates REST and GraphQL APIs automatically based on your database schema.

Why Choose Next.js for Your Frontend?

Next.js extends React with production-ready features that streamline development:

  • File-based Routing: Automatic route generation from folder structure
  • Server-Side Rendering (SSR): Improved SEO and initial load performance
  • Static Site Generation (SSG): Pre-built pages for optimal performance
  • API Routes: Backend functionality within the same codebase
  • Image Optimization: Automatic image processing and lazy loading

Key Benefits of Combining Supabase with Next.js

The synergy between these technologies creates several advantages:

  • Rapid Development: Auto-generated APIs and built-in authentication reduce boilerplate code
  • Type Safety: TypeScript support across the entire stack
  • Scalability: Both platforms handle growth automatically
  • Developer Experience: Excellent tooling and documentation
  • Cost Efficiency: Generous free tiers for development and small applications

Project Setup and Tooling

Setting Up a New Next.js Project

Create a new Next.js application with TypeScript support:

npx create-next-app@latest my-crud-app --typescript --tailwind --eslint --app
cd my-crud-app
Enter fullscreen mode Exit fullscreen mode

This command creates a modern Next.js project with the App Router, TypeScript configuration, and Tailwind CSS for styling.

Installing and Configuring Supabase Client SDK

Install the Supabase JavaScript client library:

npm install @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Create a Supabase client configuration file at lib/supabase.ts:

import { createClient } from '@supabase/supabase-js'

const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

export const supabase = createClient(supabaseUrl, supabaseAnonKey)
Enter fullscreen mode Exit fullscreen mode

Environment Variables: Keeping Secrets Safe

Create a .env.local file in your project root:

NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
Enter fullscreen mode Exit fullscreen mode

These variables are prefixed with NEXT_PUBLIC_ to make them available in the browser. Never expose service role keys in client-side code.

Designing the Database with Supabase

Creating Tables and Relationships in Supabase

Navigate to your Supabase dashboard and create a new table for your CRUD application. For this example, we'll create a "posts" table:

CREATE TABLE posts (
  id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  content TEXT,
  author_id UUID REFERENCES auth.users(id),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Create an index for better query performance
CREATE INDEX posts_author_id_idx ON posts(author_id);
Enter fullscreen mode Exit fullscreen mode

Auto-Generated APIs: Let Supabase Do the Heavy Lifting

Supabase automatically generates REST and GraphQL APIs based on your database schema. Once you create the table, endpoints become immediately available:

  • GET /rest/v1/posts - Retrieve all posts
  • POST /rest/v1/posts - Create a new post
  • PATCH /rest/v1/posts?id=eq.{id} - Update a specific post
  • DELETE /rest/v1/posts?id=eq.{id} - Delete a specific post

Configuring Row-Level Security (RLS) for Better Control

Enable Row-Level Security to ensure users can only access their own data:

-- Enable RLS on the posts table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Policy for users to read their own posts
CREATE POLICY "Users can read own posts" ON posts
  FOR SELECT USING (auth.uid() = author_id);

-- Policy for users to insert their own posts
CREATE POLICY "Users can insert own posts" ON posts
  FOR INSERT WITH CHECK (auth.uid() = author_id);

-- Policy for users to update their own posts
CREATE POLICY "Users can update own posts" ON posts
  FOR UPDATE USING (auth.uid() = author_id);

-- Policy for users to delete their own posts
CREATE POLICY "Users can delete own posts" ON posts
  FOR DELETE USING (auth.uid() = author_id);
Enter fullscreen mode Exit fullscreen mode

Building the User Interface

Planning the App Layout and Navigation

Create a clean, responsive layout with navigation. Design your component structure:

components/
├── Layout.tsx
├── Navigation.tsx
├── PostCard.tsx
├── PostForm.tsx
└── LoadingSpinner.tsx
Enter fullscreen mode Exit fullscreen mode

Creating Pages with Next.js File-Based Routing

Organize your pages using the App Router structure:

app/
├── page.tsx (home page)
├── login/
│   └── page.tsx
├── dashboard/
│   └── page.tsx
├── posts/
│   ├── page.tsx
│   ├── new/
│   │   └── page.tsx
│   └── [id]/
│       └── edit/
│           └── page.tsx
└── layout.tsx
Enter fullscreen mode Exit fullscreen mode

Adding Tailwind CSS for Styling

Tailwind CSS was included during project setup. Create a consistent design system with utility classes:

// components/Button.tsx
interface ButtonProps {
  children: React.ReactNode
  variant?: 'primary' | 'secondary' | 'danger'
  onClick?: () => void
  disabled?: boolean
}

export function Button({ children, variant = 'primary', onClick, disabled }: ButtonProps) {
  const baseClasses = 'px-4 py-2 rounded-md font-medium transition-colors'
  const variantClasses = {
    primary: 'bg-blue-600 text-white hover:bg-blue-700',
    secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
    danger: 'bg-red-600 text-white hover:bg-red-700'
  }

  return (
    <button
      className={`${baseClasses} ${variantClasses[variant]} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  )
}
Enter fullscreen mode Exit fullscreen mode

User Authentication with Supabase

Enabling Email/Password Authentication

In your Supabase dashboard, navigate to Authentication > Settings and enable email authentication. Configure your site URL and redirect URLs for production deployment.

Creating Signup and Login Forms

Create authentication forms with proper validation:

// components/AuthForm.tsx
'use client'
import { useState } from 'react'
import { supabase } from '@/lib/supabase'

interface AuthFormProps {
  mode: 'login' | 'signup'
}

export function AuthForm({ mode }: AuthFormProps) {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setError(null)

    const { error } = mode === 'login' 
      ? await supabase.auth.signInWithPassword({ email, password })
      : await supabase.auth.signUp({ email, password })

    if (error) {
      setError(error.message)
    }
    setLoading(false)
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>
      <div>
        <label htmlFor="password" className="block text-sm font-medium text-gray-700">
          Password
        </label>
        <input
          id="password"
          type="password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>
      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}
      <button
        type="submit"
        disabled={loading}
        className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? 'Loading...' : mode === 'login' ? 'Sign In' : 'Sign Up'}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Managing Sessions and Protecting Routes

Create a custom hook for authentication state management:

// hooks/useAuth.ts
'use client'
import { useEffect, useState } from 'react'
import { User } from '@supabase/supabase-js'
import { supabase } from '@/lib/supabase'

export function useAuth() {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    // Get initial session
    supabase.auth.getSession().then(({ data: { session } }) => {
      setUser(session?.user ?? null)
      setLoading(false)
    })

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

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

  return { user, loading }
}
Enter fullscreen mode Exit fullscreen mode

Implementing Create Functionality

Creating a Form to Add New Records

Build a reusable form component for creating posts:

// components/PostForm.tsx
'use client'
import { useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/hooks/useAuth'

interface PostFormData {
  title: string
  content: string
}

export function PostForm({ onSuccess }: { onSuccess?: () => void }) {
  const { user } = useAuth()
  const [formData, setFormData] = useState<PostFormData>({
    title: '',
    content: ''
  })
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    if (!user) return

    setLoading(true)
    setError(null)

    const { error } = await supabase
      .from('posts')
      .insert([
        {
          title: formData.title,
          content: formData.content,
          author_id: user.id
        }
      ])

    if (error) {
      setError(error.message)
    } else {
      setFormData({ title: '', content: '' })
      onSuccess?.()
    }
    setLoading(false)
  }

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="title" className="block text-sm font-medium text-gray-700">
          Title
        </label>
        <input
          id="title"
          type="text"
          value={formData.title}
          onChange={(e) => setFormData({ ...formData, title: e.target.value })}
          required
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>
      <div>
        <label htmlFor="content" className="block text-sm font-medium text-gray-700">
          Content
        </label>
        <textarea
          id="content"
          value={formData.content}
          onChange={(e) => setFormData({ ...formData, content: e.target.value })}
          rows={4}
          className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
        />
      </div>
      {error && (
        <div className="text-red-600 text-sm">{error}</div>
      )}
      <button
        type="submit"
        disabled={loading}
        className="bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {loading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Submitting Data to Supabase

The form above demonstrates the basic pattern for inserting data into Supabase. Key points:

  • Always validate user authentication before operations
  • Include proper error handling for network issues
  • Reset form state after successful submission
  • Use loading states to provide user feedback

Handling Form Validation and Errors

Implement client-side validation with libraries like Zod for type safety:

npm install zod
Enter fullscreen mode Exit fullscreen mode
import { z } from 'zod'

const postSchema = z.object({
  title: z.string().min(1, 'Title is required').max(255, 'Title too long'),
  content: z.string().optional()
})

// Validate before submission
const validation = postSchema.safeParse(formData)
if (!validation.success) {
  setError(validation.error.issues[0].message)
  return
}
Enter fullscreen mode Exit fullscreen mode

Reading Data from Supabase

Fetching Data Using Supabase's JavaScript Client

Create a custom hook for fetching posts:

// hooks/usePosts.ts
'use client'
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'

interface Post {
  id: string
  title: string
  content: string
  created_at: string
  updated_at: string
}

export function usePosts() {
  const [posts, setPosts] = useState<Post[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    fetchPosts()
  }, [])

  const fetchPosts = async () => {
    try {
      const { data, error } = await supabase
        .from('posts')
        .select('*')
        .order('created_at', { ascending: false })

      if (error) throw error
      setPosts(data || [])
    } catch (err) {
      setError(err instanceof Error ? err.message : 'An error occurred')
    } finally {
      setLoading(false)
    }
  }

  return { posts, loading, error, refetch: fetchPosts }
}
Enter fullscreen mode Exit fullscreen mode

Displaying Data with React Components

Create a component to display the list of posts:

// components/PostList.tsx
'use client'
import { usePosts } from '@/hooks/usePosts'
import { LoadingSpinner } from './LoadingSpinner'

export function PostList() {
  const { posts, loading, error } = usePosts()

  if (loading) return <LoadingSpinner />
  if (error) return <div className="text-red-600">Error: {error}</div>
  if (posts.length === 0) return <div className="text-gray-500">No posts found</div>

  return (
    <div className="space-y-4">
      {posts.map((post) => (
        <div key={post.id} className="bg-white p-6 rounded-lg shadow">
          <h3 className="text-lg font-semibold mb-2">{post.title}</h3>
          {post.content && (
            <p className="text-gray-600 mb-4">{post.content}</p>
          )}
          <div className="text-sm text-gray-400">
            Created: {new Date(post.created_at).toLocaleDateString()}
          </div>
        </div>
      ))}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Adding Loading States and Error Handling

Implement comprehensive loading and error states throughout your application:

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

// components/ErrorBoundary.tsx
'use client'
import { Component, ReactNode } from 'react'

interface Props {
  children: ReactNode
}

interface State {
  hasError: boolean
  error?: Error
}

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

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error }
  }

  render() {
    if (this.state.hasError) {
      return (
        <div className="text-center p-8">
          <h2 className="text-xl font-semibold text-red-600 mb-2">Something went wrong</h2>
          <p className="text-gray-600">Please refresh the page and try again.</p>
        </div>
      )
    }

    return this.props.children
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

This guide has walked you through building a complete CRUD application with Supabase and Next.js. You've learned how to implement authentication, manage data operations, add real-time features, and deploy to production. The combination of these technologies provides a powerful foundation for building modern web applications that can scale with your needs.

Continue exploring the vast capabilities of both platforms to build even more sophisticated applications. The skills you've developed here will serve as a solid foundation for your future web development projects.

Top comments (0)