Skip to main content

How to Build a Full-Stack AI Tools Directory App: The Complete Developer’s Guide (Next.js + Supabase)


A high-tech developer workspace featuring a sleek laptop on a dark wooden desk. The laptop screen displays code and an AI tools directory UI. Glowing neon purple, blue, and orange holographic icons representing AI, Next.js, database, and APIs float around the screen. Above the laptop, the glowing text reads "How to Build a Full-Stack AI Tools Directory App".

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 your SUPABASE_SERVICE_ROLE_KEY to 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();
Seed Your Database

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

Tool Card Component

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_URL
    • SUPABASE_SERVICE_ROLE_KEY
    • NEXT_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 revalidate tags 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

Popular posts from this blog

Nuclear-Powered AI Data Centers: How Small Modular Reactors (SMRs) Are Fueling the 2026 Hyperscale Boom

Toyota Aqua 2026 Review: Real-World Fuel Efficiency & Hidden Features

How Artificial Intelligence (AI) is Reshaping Our Daily Lives