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
Full-stack Developer & Founder
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.