How to Build a Full-Stack AI Tools Directory App: The Complete Developer’s Guide (Next.js + Supabase)
The AI landscape is evolving at breakneck speed. With dozens of new tools launching every week, users are overwhelmed and desperate for curated, searchable directories. Building an AI Tools Directory is not just a great portfolio project; it is a viable SaaS business model that can be monetized through featured listings, affiliate links, and sponsored placements.
This guide will walk you through building a production-ready AI Tools Directory using Next.js 14 (App Router), Supabase, and Tailwind CSS. We will cover database schema design, API routes, frontend filtering, SEO optimization, and deployment.
1. The Tech Stack
We have chosen this stack for its speed, scalability, and free-tier generosity:
| Technology | Purpose | Why We Chose It |
|---|---|---|
| Next.js 14 | Full-stack Framework | Server Components, App Router, built-in API routes, and excellent SEO support. |
| Supabase | Database & Auth | Open-source Firebase alternative. Provides PostgreSQL, real-time subscriptions, and Row Level Security (RLS). |
| Tailwind CSS | Styling | Utility-first CSS framework for rapid UI development without leaving your HTML. |
| Lucide React | Icons | Lightweight, consistent icon library perfect for directory cards. |
| Vercel | Deployment | Zero-config hosting optimized for Next.js with automatic preview deployments. |
2. Project Initialization
Open your terminal and create a new Next.js project with TypeScript and Tailwind pre-configured:
npx create-next-app@latest ai-tools-directory --typescript --tailwind --eslint --app --src-dir
cd ai-tools-directory
npm install @supabase/supabase-js lucide-react clsx tailwind-merge
Create a .env.local file in your root directory immediately:
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
⚠️ Critical Security Note: Never expose yourSUPABASE_SERVICE_ROLE_KEYto the client-side. Only use it in Server Components or API Routes. For client-side operations, use the anon/public key (NEXT_PUBLIC_SUPABASE_ANON_KEY). In this guide, we use the service key only in secure server-side API routes.
3. Supabase Database Architecture
Log into your Supabase dashboard, navigate to the SQL Editor, and run the following script. This creates a robust schema with proper indexing and security policies.
-- Create the main tools table
CREATE TABLE IF NOT EXISTS ai_tools (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT NOT NULL,
long_description TEXT,
category TEXT NOT NULL,
subcategory TEXT,
url TEXT NOT NULL UNIQUE,
image_url TEXT,
pricing_model TEXT CHECK (pricing_model IN ('free', 'freemium', 'paid', 'open_source')),
upvotes INTEGER DEFAULT 0,
is_featured BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes for fast searching and filtering
CREATE INDEX idx_ai_tools_category ON ai_tools(category);
CREATE INDEX idx_ai_tools_pricing ON ai_tools(pricing_model);
CREATE INDEX idx_ai_tools_featured ON ai_tools(is_featured);
CREATE INDEX idx_ai_tools_name_trgm ON ai_tools USING gin(name gin_trgm_ops);
-- Enable Row Level Security
ALTER TABLE ai_tools ENABLE ROW LEVEL SECURITY;
-- Public can read all tools
CREATE POLICY "Public read access"
ON ai_tools FOR SELECT
USING (true);
-- Only authenticated admins can insert/update/delete
CREATE POLICY "Admin write access"
ON ai_tools FOR ALL
USING (auth.jwt()->>'role' = 'admin')
WITH CHECK (auth.jwt()->>'role' = 'admin');
-- Function to auto-update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql;
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON ai_tools
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
Populate your directory with initial data so you have content to work with during development:
INSERT INTO ai_tools (name, slug, description, category, url, pricing_model, is_featured) VALUES
('Jasper', 'jasper', 'AI writing assistant for marketing copy and blog posts', 'Writing', 'https://jasper.ai', 'freemium', true),
('Midjourney', 'midjourney', 'State-of-the-art text-to-image generation via Discord', 'Images', 'https://midjourney.com', 'paid', true),
('ElevenLabs', 'elevenlabs', 'Realistic AI voice synthesis and cloning technology', 'Audio', 'https://elevenlabs.io', 'freemium', false),
('GitHub Copilot', 'github-copilot', 'AI pair programmer that suggests code in real-time', 'Coding', 'https://github.com/features/copilot', 'paid', true),
('Perplexity', 'perplexity', 'Conversational search engine with cited sources', 'Research', 'https://perplexity.ai', 'freemium', false),
('Notion AI', 'notion-ai', 'Integrated AI workspace for notes, docs, and projects', 'Productivity', 'https://notion.so/product/ai', 'paid', false);
4. Supabase Client Configuration
Create a dedicated Supabase client utility to avoid repetitive initialization:
src/lib/supabase/server.ts
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY!;
export const getServerSupabase = () => {
return createClient(supabaseUrl, supabaseKey, {
auth: { persistSession: false },
});
};
src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export const getClientSupabase = () => {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
};
5. Backend API Route
Create a flexible API endpoint that supports filtering, searching, and pagination:
src/app/api/tools/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getServerSupabase } from '@/lib/supabase/server';
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const category = searchParams.get('category');
const pricing = searchParams.get('pricing');
const search = searchParams.get('search');
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '12');
const offset = (page - 1) * limit;
const supabase = getServerSupabase();
let query = supabase.from('ai_tools').select('*', { count: 'exact' });
// Apply filters dynamically
if (category && category !== 'all') {
query = query.eq('category', category);
}
if (pricing && pricing !== 'all') {
query = query.eq('pricing_model', pricing);
}
if (search) {
query = query.or(`name.ilike.%${search}%,description.ilike.%${search}%`);
}
// Order: Featured first, then newest
query = query.order('is_featured', { ascending: false })
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
const { data, error, count } = await query;
if (error) {
return NextResponse.json({ error: error.message }, { status: 500 });
}
return NextResponse.json({
tools: data,
total: count,
page,
totalPages: Math.ceil((count || 0) / limit),
});
}
6. Frontend Implementation
src/components/ToolCard.tsx
import Link from 'next/link';
import { ExternalLink, Star } from 'lucide-react';
interface ToolCardProps {
tool: {
id: number;
name: string;
slug: string;
description: string;
category: string;
url: string;
image_url: string | null;
pricing_model: string;
is_featured: boolean;
upvotes: number;
};
}
export default function ToolCard({ tool }: ToolCardProps) {
const pricingColors: Record<string, string> = {
free: 'bg-green-100 text-green-800',
freemium: 'bg-blue-100 text-blue-800',
paid: 'bg-purple-100 text-purple-800',
open_source: 'bg-gray-100 text-gray-800',
};
return (
<div className="group relative bg-white rounded-xl border border-gray-200 p-5 hover:shadow-lg transition-all duration-300 hover:-translate-y-1">
{tool.is_featured && (
<div className="absolute -top-3 -right-3 bg-yellow-400 text-yellow-900 text-xs font-bold px-3 py-1 rounded-full shadow-sm flex items-center gap-1">
<Star size={12} fill="currentColor" /> Featured
</div>
)}
<div className="flex items-start justify-between mb-3">
<div className="w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{tool.image_url ? (
<img src={tool.image_url} alt={tool.name} className="w-full h-full object-cover" />
) : (
<span className="text-2xl font-bold text-gray-400">{tool.name[0]}</span>
)}
</div>
<span className={`text-xs font-medium px-2.5 py-1 rounded-full ${pricingColors[tool.pricing_model] || 'bg-gray-100'}`}>
{tool.pricing_model.replace('_', ' ').toUpperCase()}
</span>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-1 group-hover:text-blue-600 transition-colors">
{tool.name}
</h3>
<p className="text-sm text-gray-600 mb-4 line-clamp-2">{tool.description}</p>
<div className="flex items-center justify-between pt-3 border-t border-gray-100">
<span className="text-xs text-gray-500 font-medium">{tool.category}</span>
<a
href={tool.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-sm font-medium text-blue-600 hover:text-blue-700 transition-colors"
>
Visit Site <ExternalLink size={14} />
</a>
</div>
</div>
);
}
Main Directory Page
src/app/page.tsx
'use client';
import { useState, useEffect, useCallback } from 'react';
import ToolCard from '@/components/ToolCard';
import { Search, Filter } from 'lucide-react';
const CATEGORIES = ['All', 'Writing', 'Images', 'Audio', 'Coding', 'Research', 'Productivity', 'Video'];
const PRICING_OPTIONS = ['All', 'Free', 'Freemium', 'Paid', 'Open Source'];
export default function AIDirectoryPage() {
const [tools, setTools] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [category, setCategory] = useState('All');
const [pricing, setPricing] = useState('All');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const fetchTools = useCallback(async () => {
setLoading(true);
const params = new URLSearchParams({
page: page.toString(),
limit: '12',
...(category !== 'All' && { category }),
...(pricing !== 'All' && { pricing: pricing.toLowerCase() }),
...(search && { search }),
});
try {
const res = await fetch(`/api/tools?${params}`);
const data = await res.json();
setTools(data.tools);
setTotalPages(data.totalPages);
} catch (err) {
console.error('Failed to fetch tools:', err);
} finally {
setLoading(false);
}
}, [page, category, pricing, search]);
useEffect(() => {
fetchTools();
}, [fetchTools]);
return (
<main className="min-h-screen bg-gray-50">
{/* Hero Section */}
<section className="bg-gradient-to-br from-blue-600 via-indigo-600 to-purple-700 text-white py-20 px-4">
<div className="max-w-4xl mx-auto text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
Discover the Best AI Tools
</h1>
<p className="text-xl text-blue-100 mb-8 max-w-2xl mx-auto">
Curated directory of {tools.length > 0 ? 'hundreds' : 'thousands'} of AI tools to supercharge your workflow. Updated daily.
</p>
{/* Search Bar */}
<div className="relative max-w-xl mx-auto">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="Search AI tools..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-12 pr-4 py-4 rounded-full text-gray-900 text-lg shadow-xl focus:outline-none focus:ring-4 focus:ring-blue-300"
/>
</div>
</div>
</section>
{/* Filters */}
<section className="max-w-7xl mx-auto px-4 py-8">
<div className="flex flex-wrap gap-4 items-center">
<div className="flex items-center gap-2 text-gray-700 font-medium">
<Filter size={18} /> Filters:
</div>
<select
value={category}
onChange={(e) => { setCategory(e.target.value); setPage(1); }}
className="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{CATEGORIES.map(cat => (
<option key={cat} value={cat}>{cat}</option>
))}
</select>
<select
value={pricing}
onChange={(e) => { setPricing(e.target.value); setPage(1); }}
className="px-4 py-2 border border-gray-300 rounded-lg bg-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
{PRICING_OPTIONS.map(opt => (
<option key={opt} value={opt}>{opt}</option>
))}
</select>
</div>
</section>
{/* Tools Grid */}
<section className="max-w-7xl mx-auto px-4 pb-16">
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{[...Array(8)].map((_, i) => (
<div key={i} className="bg-white rounded-xl p-5 animate-pulse">
<div className="w-12 h-12 bg-gray-200 rounded-lg mb-3" />
<div className="h-5 bg-gray-200 rounded w-3/4 mb-2" />
<div className="h-4 bg-gray-200 rounded w-full mb-4" />
<div className="h-4 bg-gray-200 rounded w-1/2" />
</div>
))}
</div>
) : tools.length === 0 ? (
<div className="text-center py-20">
<p className="text-xl text-gray-500">No tools found matching your criteria.</p>
<button
onClick={() => { setSearch(''); setCategory('All'); setPricing('All'); }}
className="mt-4 text-blue-600 hover:underline"
>
Clear all filters
</button>
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{tools.map((tool) => (
<ToolCard key={tool.id} tool={tool} />
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center gap-2 mt-12">
<button
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
className="px-4 py-2 border rounded-lg disabled:opacity-50 hover:bg-gray-100"
>
Previous
</button>
<span className="px-4 py-2 text-gray-600">
Page {page} of {totalPages}
</span>
<button
disabled={page === totalPages}
onClick={() => setPage(p => p + 1)}
className="px-4 py-2 border rounded-lg disabled:opacity-50 hover:bg-gray-100"
>
Next
</button>
</div>
)}
</>
)}
</section>
</main>
);
}
7. SEO Optimization Strategy
A directory lives or dies by organic search traffic. Implement these critical SEO enhancements:
Dynamic Metadata Per Tool
Create individual tool pages with unique meta tags:
src/app/tools/[slug]/page.tsx
import { Metadata } from 'next';
import { getServerSupabase } from '@/lib/supabase/server';
type Props = { params: { slug: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const supabase = getServerSupabase();
const { data: tool } = await supabase
.from('ai_tools')
.select('name, description')
.eq('slug', params.slug)
.single();
return {
title: `${tool?.name} - AI Tool Review & Details | AI Directory`,
description: tool?.description || `Learn about ${tool?.name}, the AI tool for ${tool?.category}.`,
openGraph: {
title: `${tool?.name} - AI Tool Review`,
description: tool?.description,
type: 'website',
},
};
}
export default async function ToolDetailPage({ params }: Props) {
const supabase = getServerSupabase();
const { data: tool } = await supabase
.from('ai_tools')
.select('*')
.eq('slug', params.slug)
.single();
if (!tool) return <div>Tool not found</div>;
return (
<article className="max-w-3xl mx-auto px-4 py-16">
<h1 className="text-4xl font-bold mb-4">{tool.name}</h1>
<p className="text-xl text-gray-600 mb-8">{tool.long_description || tool.description}</p>
<a
href={tool.url}
target="_blank"
rel="noopener noreferrer"
className="inline-block bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition"
>
Visit {tool.name} →
</a>
</article>
);
}
Structured Data (JSON-LD)
Add software application schema to help Google understand your content:
// Inside ToolDetailPage component
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": tool.name,
"description": tool.description,
"applicationCategory": tool.category,
"offers": {
"@type": "Offer",
"price": tool.pricing_model === 'free' ? "0" : "0",
"priceCurrency": "USD"
},
"url": tool.url
})
}}
/>
8. Deployment Checklist
- Push your code to GitHub
- Connect your repository to Vercel
- Add environment variables in Vercel Dashboard:
NEXT_PUBLIC_SUPABASE_URLSUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_SUPABASE_ANON_KEY
- Deploy → Vercel automatically builds and deploys
- Configure custom domain in Vercel settings
- Submit sitemap to Google Search Console
9. Monetization & Growth Roadmap
Once your directory is live, implement these revenue streams:
| Strategy | Implementation | Revenue Potential |
|---|---|---|
| Featured Listings | Charge $49-$199/month for pinned placement | High (recurring) |
| Affiliate Links | Join partner programs (Jasper, Notion, etc.) | Medium (passive) |
| Newsletter Sponsorships | Weekly AI tools digest with sponsor slots | Medium-High |
| API Access | Sell your curated dataset to other developers | Low-Medium |
| Submit Fee | Charge $10-$29 for expedited tool review | Low (one-time) |
10. Common Pitfalls to Avoid
- ❌ Don't skip RLS: Without Row Level Security, anyone can modify your database via the browser console. Always test policies thoroughly.
- ❌ Don't hardcode API keys: Use environment variables exclusively. Committing keys to Git is a critical security vulnerability.
- ❌ Don't ignore mobile: 60%+ of directory traffic comes from mobile devices. Test every breakpoint.
- ❌ Don't launch empty: Seed at least 50-100 quality tools before promoting. An empty directory kills trust instantly.
- ❌ Don't forget caching: Add
revalidatetags to your API routes to reduce Supabase load and improve response times.
Final Thoughts
Building an AI Tools Directory is one of the most practical ways to learn full-stack development while creating something genuinely useful. The combination of Next.js and Supabase gives you a production-grade foundation that can scale from zero to millions of visitors without architectural changes.
Start building today. Ship your MVP with 50 tools. Iterate based on user feedback. The AI gold rush is still in its early innings, and there has never been a better time to build the map that guides others through it.

Comments
Post a Comment