#Next.js#Supabase#Full-Stack#React#Database

Full-Stack Development with Next.js and Supabase 2025

Build complete web applications using Next.js 15 and Supabase with authentication, real-time features, and database management.

Taha Karahan

Taha Karahan

Full-stack Developer & Founder

8/4/2025
14 dk okuma

Full-Stack Development with Next.js 15 and Supabase

The combination of Next.js 15 and Supabase creates a powerful full-stack development experience. This comprehensive guide will show you how to build modern, scalable applications using these cutting-edge technologies.

Why Next.js 15 + Supabase?

Next.js 15 Advantages

  • Server Components: Better performance and SEO
  • App Router: Improved routing and layouts
  • Server Actions: Type-safe server-side operations
  • Turbopack: Faster development builds
  • Built-in TypeScript: First-class TypeScript support

Supabase Benefits

  • PostgreSQL: Full-featured relational database
  • Real-time: Live data synchronization
  • Authentication: Built-in auth with social providers
  • Storage: File storage and CDN
  • Edge Functions: Serverless compute at the edge

Project Setup

Initialize Next.js 15 Project

npx create-next-app@latest my-app --typescript --tailwind --eslint --app
cd my-app

Install Supabase Dependencies

npm install @supabase/supabase-js @supabase/ssr
npm install -D @supabase/cli

Environment Configuration

Create .env.local:

NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key

Supabase Client Setup

Client-Side Configuration

// lib/supabase/client.ts
import { createClientComponentClient } from '@supabase/ssr'
import { Database } from '@/types/database'

export const createClient = () => 
  createClientComponentClient<Database>()

Server-Side Configuration

// lib/supabase/server.ts
import { createServerComponentClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { Database } from '@/types/database'

export const createServerClient = () =>
  createServerComponentClient<Database>({
    cookies,
  })

Middleware for Auth

// middleware.ts
import { createMiddlewareClient } from '@supabase/ssr'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export async function middleware(req: NextRequest) {
  const res = NextResponse.next()
  const supabase = createMiddlewareClient({ req, res })

  const {
    data: { session },
  } = await supabase.auth.getSession()

  // Redirect to login if not authenticated
  if (!session && req.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', req.url))
  }

  return res
}

export const config = {
  matcher: ['/dashboard/:path*', '/api/protected/:path*']
}

Database Schema Design

SQL Schema

-- Enable Row Level Security
alter database postgres set "app.jwt_secret" to 'your-jwt-secret';

-- Users profile table
create table profiles (
  id uuid references auth.users on delete cascade not null primary key,
  username text unique,
  full_name text,
  avatar_url text,
  website text,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null,
  updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);

-- Enable RLS
alter table profiles enable row level security;

-- Profiles policies
create policy "Public profiles are viewable by everyone." on profiles
  for select using (true);

create policy "Users can insert their own profile." on profiles
  for insert with check (auth.uid() = id);

create policy "Users can update own profile." on profiles
  for update using (auth.uid() = id);

-- Posts table
create table posts (
  id uuid default gen_random_uuid() primary key,
  title text not null,
  content text,
  author_id uuid references profiles(id) on delete cascade not null,
  published boolean default false,
  created_at timestamp with time zone default timezone('utc'::text, now()) not null,
  updated_at timestamp with time zone default timezone('utc'::text, now()) not null
);

alter table posts enable row level security;

-- Posts policies
create policy "Posts are viewable by everyone." on posts
  for select using (published = true);

create policy "Users can create their own posts." on posts
  for insert with check (auth.uid() = author_id);

create policy "Users can update their own posts." on posts
  for update using (auth.uid() = author_id);

TypeScript Types

Generate types from your database:

npx supabase gen types typescript --project-id your-project-id > types/database.ts

Authentication Implementation

Auth Provider Component

// components/auth/auth-provider.tsx
'use client'

import { createContext, useContext, useEffect, useState } from 'react'
import { User, Session } from '@supabase/supabase-js'
import { createClient } from '@/lib/supabase/client'

interface AuthContextType {
  user: User | null
  session: Session | null
  loading: boolean
  signOut: () => Promise<void>
}

const AuthContext = createContext<AuthContextType>({
  user: null,
  session: null,
  loading: true,
  signOut: async () => {},
})

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null)
  const [session, setSession] = useState<Session | null>(null)
  const [loading, setLoading] = useState(true)

  const supabase = createClient()

  useEffect(() => {
    const getSession = async () => {
      const { data: { session } } = await supabase.auth.getSession()
      setSession(session)
      setUser(session?.user ?? null)
      setLoading(false)
    }

    getSession()

    const { data: { subscription } } = supabase.auth.onAuthStateChange(
      async (event, session) => {
        setSession(session)
        setUser(session?.user ?? null)
        setLoading(false)
      }
    )

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

  const signOut = async () => {
    await supabase.auth.signOut()
  }

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

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

Login Component

// components/auth/login-form.tsx
'use client'

import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'

export function LoginForm() {
  const [email, setEmail] = useState('')
  const [password, setPassword] = useState('')
  const [loading, setLoading] = useState(false)
  const [message, setMessage] = useState('')

  const supabase = createClient()

  const handleLogin = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)
    setMessage('')

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    })

    if (error) {
      setMessage(error.message)
    } else {
      setMessage('Login successful!')
    }

    setLoading(false)
  }

  const handleGoogleLogin = async () => {
    const { error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback`,
      },
    })

    if (error) {
      setMessage(error.message)
    }
  }

  return (
    <form onSubmit={handleLogin} className="space-y-4">
      <div>
        <Input
          type="email"
          placeholder="Email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          required
        />
      </div>
      <div>
        <Input
          type="password"
          placeholder="Password"
          value={password}
          onChange={(e) => setPassword(e.target.value)}
          required
        />
      </div>
      <Button type="submit" disabled={loading} className="w-full">
        {loading ? 'Signing in...' : 'Sign In'}
      </Button>
      <Button
        type="button"
        variant="outline"
        onClick={handleGoogleLogin}
        className="w-full"
      >
        Sign in with Google
      </Button>
      {message && (
        <p className={`text-sm ${message.includes('successful') ? 'text-green-600' : 'text-red-600'}`}>
          {message}
        </p>
      )}
    </form>
  )
}

Server Actions with Next.js 15

Post Management Actions

// lib/actions/posts.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createServerClient } from '@/lib/supabase/server'

export async function createPost(formData: FormData) {
  const supabase = createServerClient()

  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }

  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const published = formData.get('published') === 'true'

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

  if (error) {
    throw new Error(error.message)
  }

  revalidatePath('/dashboard/posts')
  redirect('/dashboard/posts')
}

export async function updatePost(id: string, formData: FormData) {
  const supabase = createServerClient()

  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }

  const title = formData.get('title') as string
  const content = formData.get('content') as string
  const published = formData.get('published') === 'true'

  const { error } = await supabase
    .from('posts')
    .update({
      title,
      content,
      published,
      updated_at: new Date().toISOString(),
    })
    .eq('id', id)
    .eq('author_id', user.id)

  if (error) {
    throw new Error(error.message)
  }

  revalidatePath('/dashboard/posts')
  revalidatePath(`/posts/${id}`)
}

export async function deletePost(id: string) {
  const supabase = createServerClient()

  const { data: { user } } = await supabase.auth.getUser()
  
  if (!user) {
    redirect('/login')
  }

  const { error } = await supabase
    .from('posts')
    .delete()
    .eq('id', id)
    .eq('author_id', user.id)

  if (error) {
    throw new Error(error.message)
  }

  revalidatePath('/dashboard/posts')
}

Post Form Component

// components/posts/post-form.tsx
import { createPost, updatePost } from '@/lib/actions/posts'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'

interface PostFormProps {
  post?: {
    id: string
    title: string
    content: string
    published: boolean
  }
}

export function PostForm({ post }: PostFormProps) {
  const action = post ? updatePost.bind(null, post.id) : createPost

  return (
    <form action={action} className="space-y-4">
      <div>
        <Input
          name="title"
          placeholder="Post title"
          defaultValue={post?.title}
          required
        />
      </div>
      <div>
        <Textarea
          name="content"
          placeholder="Post content"
          defaultValue={post?.content}
          rows={10}
        />
      </div>
      <div className="flex items-center space-x-2">
        <Checkbox
          name="published"
          value="true"
          defaultChecked={post?.published}
        />
        <label>Published</label>
      </div>
      <Button type="submit">
        {post ? 'Update Post' : 'Create Post'}
      </Button>
    </form>
  )
}

Real-time Features

Real-time Posts Component

// components/posts/posts-live.tsx
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Database } from '@/types/database'

type Post = Database['public']['Tables']['posts']['Row'] & {
  profiles: Database['public']['Tables']['profiles']['Row']
}

export function PostsLive({ initialPosts }: { initialPosts: Post[] }) {
  const [posts, setPosts] = useState<Post[]>(initialPosts)
  const supabase = createClient()

  useEffect(() => {
    const channel = supabase
      .channel('posts-changes')
      .on(
        'postgres_changes',
        {
          event: '*',
          schema: 'public',
          table: 'posts',
          filter: 'published=eq.true',
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            // Fetch the new post with profile data
            fetchPostWithProfile(payload.new.id).then((newPost) => {
              if (newPost) {
                setPosts((current) => [newPost, ...current])
              }
            })
          } else if (payload.eventType === 'UPDATE') {
            setPosts((current) =>
              current.map((post) =>
                post.id === payload.new.id ? { ...post, ...payload.new } : post
              )
            )
          } else if (payload.eventType === 'DELETE') {
            setPosts((current) =>
              current.filter((post) => post.id !== payload.old.id)
            )
          }
        }
      )
      .subscribe()

    return () => {
      supabase.removeChannel(channel)
    }
  }, [supabase])

  const fetchPostWithProfile = async (postId: string) => {
    const { data } = await supabase
      .from('posts')
      .select(`
        *,
        profiles (*)
      `)
      .eq('id', postId)
      .eq('published', true)
      .single()

    return data
  }

  return (
    <div className="space-y-6">
      {posts.map((post) => (
        <article key={post.id} className="border rounded-lg p-6">
          <h2 className="text-2xl font-bold mb-2">{post.title}</h2>
          <p className="text-gray-600 mb-4">
            By {post.profiles.full_name || post.profiles.username}
          </p>
          <div className="prose max-w-none">
            {post.content}
          </div>
        </article>
      ))}
    </div>
  )
}

File Storage Integration

File Upload Component

// components/upload/file-upload.tsx
'use client'

import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/components/auth/auth-provider'

export function FileUpload() {
  const [uploading, setUploading] = useState(false)
  const [uploadedUrl, setUploadedUrl] = useState<string | null>(null)
  const { user } = useAuth()
  const supabase = createClient()

  const uploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
    try {
      setUploading(true)

      if (!event.target.files || event.target.files.length === 0) {
        throw new Error('You must select a file to upload.')
      }

      const file = event.target.files[0]
      const fileExt = file.name.split('.').pop()
      const fileName = `${user?.id}-${Math.random()}.${fileExt}`
      const filePath = `uploads/${fileName}`

      const { error: uploadError } = await supabase.storage
        .from('files')
        .upload(filePath, file)

      if (uploadError) {
        throw uploadError
      }

      const { data } = supabase.storage
        .from('files')
        .getPublicUrl(filePath)

      setUploadedUrl(data.publicUrl)
    } catch (error) {
      alert('Error uploading file!')
      console.log(error)
    } finally {
      setUploading(false)
    }
  }

  return (
    <div className="space-y-4">
      <div>
        <input
          type="file"
          id="file-upload"
          accept="image/*"
          onChange={uploadFile}
          disabled={uploading}
          className="hidden"
        />
        <Button asChild>
          <label htmlFor="file-upload" className="cursor-pointer">
            {uploading ? 'Uploading...' : 'Upload File'}
          </label>
        </Button>
      </div>
      
      {uploadedUrl && (
        <div>
          <p>File uploaded successfully!</p>
          <img
            src={uploadedUrl}
            alt="Uploaded file"
            className="max-w-xs rounded-lg"
          />
        </div>
      )}
    </div>
  )
}

Advanced Patterns

Optimistic Updates

// hooks/use-optimistic-posts.ts
'use client'

import { useOptimistic } from 'react'
import { Database } from '@/types/database'

type Post = Database['public']['Tables']['posts']['Row']

export function useOptimisticPosts(initialPosts: Post[]) {
  const [optimisticPosts, addOptimisticPost] = useOptimistic(
    initialPosts,
    (state: Post[], newPost: Post) => [...state, newPost]
  )

  return {
    posts: optimisticPosts,
    addOptimisticPost,
  }
}

Data Fetching with Server Components

// app/posts/page.tsx
import { createServerClient } from '@/lib/supabase/server'
import { PostsLive } from '@/components/posts/posts-live'

export default async function PostsPage() {
  const supabase = createServerClient()

  const { data: posts } = await supabase
    .from('posts')
    .select(`
      *,
      profiles (*)
    `)
    .eq('published', true)
    .order('created_at', { ascending: false })

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">Latest Posts</h1>
      <PostsLive initialPosts={posts || []} />
    </div>
  )
}

Deployment and Production

Environment Variables

# Production .env
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

Vercel Deployment

// vercel.json
{
  "functions": {
    "app/**": {
      "runtime": "@vercel/node@2"
    }
  },
  "regions": ["iad1"]
}

Performance Optimization

Database Optimization

-- Add indexes for better performance
create index idx_posts_author_published on posts(author_id, published);
create index idx_posts_created_at on posts(created_at desc);
create index idx_profiles_username on profiles(username);

Caching Strategy

// lib/cache.ts
import { unstable_cache } from 'next/cache'
import { createServerClient } from '@/lib/supabase/server'

export const getCachedPosts = unstable_cache(
  async () => {
    const supabase = createServerClient()
    
    const { data } = await supabase
      .from('posts')
      .select('*')
      .eq('published', true)
      .order('created_at', { ascending: false })
      .limit(10)
    
    return data
  },
  ['posts'],
  {
    revalidate: 3600, // 1 hour
    tags: ['posts'],
  }
)

Conclusion

Next.js 15 and Supabase provide a powerful foundation for building modern full-stack applications. With Server Components, Server Actions, real-time capabilities, and built-in authentication, you can create feature-rich applications with excellent developer experience.

The combination offers the best of both worlds: the performance and SEO benefits of server-side rendering with the interactivity and real-time features of modern web applications.

Ready to build your next full-stack application? Contact AestheteSoft for expert Next.js and Supabase development services.


This article is part of the AestheteSoft blog series. Follow our blog for more insights on modern full-stack development.

If you liked this article, you can share it on social media.
Taha Karahan

Taha Karahan

Modern web technologies expert and system designer. Experienced in full-stack development and performance optimization.

Get Support for Your Project

Get professional development services for modern web applications.

Contact Us