A full-stack AI SaaS application that lets users upload PDF documents and chat with them using natural language. Built with Next.js, Supabase, and a fully free AI stack.
Users sign up, upload a PDF, and ask questions about it in plain English. The app finds the most relevant sections of the document and generates a grounded answer — it never makes things up because it only uses content from the uploaded file.
Example questions you can ask:
- "What is this document about?"
- "Summarize the key points"
- "What are the important clauses in section 3?"
- "Explain this in simple terms"
| Layer | Technology | Cost |
|---|---|---|
| Frontend + Backend | Next.js 16 (App Router) | Free |
| Database + Auth + Storage | Supabase | Free tier |
| Embeddings (AI fingerprints) | @xenova/transformers (runs locally) | Free forever |
| Chat answers | Groq API (Llama 3) | Free tier |
| Vector search | pgvector (built into Supabase) | Free |
| Styling | Tailwind CSS v4 | Free |
Total cost to run: $0
This app uses RAG (Retrieval-Augmented Generation), the same architecture used in enterprise AI products.
User uploads PDF
↓
Extract text from PDF (pdf2json)
↓
Split text into overlapping chunks (~500 words each)
↓
Convert each chunk to an embedding (384 numbers) — runs locally
↓
Store chunks + embeddings in Supabase (pgvector)
↓
Document is marked "ready"
User asks a question
↓
Convert question to an embedding — runs locally
↓
Find the 5 most similar chunks using vector cosine similarity
↓
Send: question + relevant chunks + chat history → Groq (Llama 3)
↓
AI generates a grounded answer based only on the document
↓
Save question + answer to messages table
↓
Return answer to user
- Email + password signup and login (Supabase Auth)
- Each user sees only their own documents (Row Level Security)
- Multi-user support — fully isolated data per user
- Drag-and-drop file upload (PDF and .txt)
- Real-time processing status: Pending → Processing → Ready
- Delete documents (removes file, chunks, and chat history)
- Document list dashboard with status badges
- ChatGPT-style chat interface per document
- Answers grounded in document content — no hallucination
- Conversation memory (last 6 messages sent as context)
- Typing indicator with animated dots
- Quick-start suggestion buttons on empty chat
- Shift+Enter for new line, Enter to send
- Landing page with feature highlights
- Consistent design system across all pages
- Loading states and error banners everywhere
- Responsive layout
docsai/
├── app/
│ ├── api/
│ │ ├── chat/
│ │ │ └── route.js # RAG chat endpoint
│ │ ├── documents/
│ │ │ └── [id]/
│ │ │ └── route.js # Delete document endpoint
│ │ ├── process/
│ │ │ └── route.js # PDF processing pipeline
│ │ └── upload/
│ │ └── route.js # File upload endpoint
│ ├── chat/
│ │ └── [id]/
│ │ └── page.js # Chat UI
│ ├── dashboard/
│ │ └── page.js # Document list
│ ├── login/
│ │ └── page.js # Login page
│ ├── signup/
│ │ └── page.js # Signup page
│ ├── upload/
│ │ └── page.js # Upload UI
│ ├── layout.js # Root layout
│ └── page.js # Landing page
├── lib/
│ ├── ai.js # Groq chat (Llama 3)
│ ├── chunker.js # Text splitting logic
│ ├── embeddings.js # Local embeddings (@xenova)
│ └── supabase.js # Supabase client
├── .env.local # Secret keys (never commit)
├── next.config.mjs
└── package.json
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| user_id | uuid | FK → auth.users |
| name | text | Original filename |
| file_path | text | Path in Supabase Storage |
| status | text | pending / processing / ready / error |
| created_at | timestamptz | Upload time |
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| document_id | uuid | FK → documents (cascade delete) |
| content | text | Raw text of this chunk |
| embedding | vector(384) | AI embedding from @xenova |
| chunk_index | int | Order within document |
| Column | Type | Description |
|---|---|---|
| id | uuid | Primary key |
| document_id | uuid | FK → documents (cascade delete) |
| user_id | uuid | FK → auth.users |
| role | text | 'user' or 'assistant' |
| content | text | Message text |
| created_at | timestamptz | Sent time |
Row Level Security is enabled on all tables — users can only access their own data.
- Node.js v18 or higher
- A Supabase account (free at supabase.com)
- A Groq API key (free at console.groq.com)
git clone https://github.com/YOUR_USERNAME/docsai.git
cd docsai
npm install- Create a new project at supabase.com
- Go to Database → Extensions and enable
vector - Open SQL Editor and run the following:
-- Enable pgvector
CREATE EXTENSION IF NOT EXISTS vector;
-- Documents table
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL,
file_path text,
status text DEFAULT 'pending',
created_at timestamptz DEFAULT now()
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users see own documents"
ON documents FOR ALL USING (auth.uid() = user_id);
-- Chunks table
CREATE TABLE chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid REFERENCES documents(id) ON DELETE CASCADE,
content text NOT NULL,
embedding vector(384),
chunk_index int,
created_at timestamptz DEFAULT now()
);
CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
ALTER TABLE chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users see own chunks"
ON chunks FOR ALL
USING (document_id IN (SELECT id FROM documents WHERE user_id = auth.uid()));
-- Messages table
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
document_id uuid REFERENCES documents(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
role text NOT NULL,
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "users see own messages"
ON messages FOR ALL USING (auth.uid() = user_id);
-- Vector search function
CREATE OR REPLACE FUNCTION match_chunks(
query_embedding vector(384),
match_count int DEFAULT 5,
filter_doc_id uuid DEFAULT NULL
)
RETURNS TABLE (id uuid, content text, document_id uuid, similarity float)
LANGUAGE sql STABLE AS $$
SELECT chunks.id, chunks.content, chunks.document_id,
1 - (chunks.embedding <=> query_embedding) AS similarity
FROM chunks
WHERE (filter_doc_id IS NULL OR chunks.document_id = filter_doc_id)
ORDER BY chunks.embedding <=> query_embedding
LIMIT match_count;
$$;- Go to Storage → create a bucket called
documents(set to Private) - Run these storage policies in SQL Editor:
CREATE POLICY "users upload own files"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'documents'
AND auth.uid()::text = (storage.foldername(name))[1]
);
CREATE POLICY "users read own files"
ON storage.objects FOR SELECT
USING (
bucket_id = 'documents'
AND auth.uid()::text = (storage.foldername(name))[1]
);- Go to Authentication → Providers and make sure Email is enabled. Turn off "Confirm email" for local development.
Supabase keys — Project Settings → API:
- Project URL
- anon public key
- service_role key (keep this secret) Groq API key — console.groq.com → API Keys → Create API Key
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_KEY=your-service-role-key
GROQ_API_KEY=gsk_your-groq-key
NEXT_PUBLIC_APP_URL=http://localhost:3000npm run devOpen http://localhost:3000.
Note: The first time you upload a PDF, the app will download the embedding model (~25MB) and cache it locally. This only happens once.
Documents are split into overlapping chunks to preserve context at boundaries:
chunkSize = 500 words
overlap = 50 words
Chunk 1: words 1 → 500
Chunk 2: words 451 → 950
Chunk 3: words 901 → 1400
...
A 10-page PDF typically produces 20–50 chunks. Each chunk gets its own embedding (384 numbers representing its meaning). When a user asks a question, the question also gets an embedding and the 5 closest chunks are retrieved using cosine similarity.
| Variable | Where to find it | Used in |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL |
Supabase → Project Settings → API | Client + Server |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Supabase → Project Settings → API | Client + Server |
SUPABASE_SERVICE_KEY |
Supabase → Project Settings → API | Server only |
GROQ_API_KEY |
console.groq.com → API Keys | Server only |
NEXT_PUBLIC_APP_URL |
Your local or production URL | Server only |
- File size: Large PDFs (50+ pages) take longer to process — the embedding step runs sequentially per chunk
- Scanned PDFs: pdf2json extracts text only; scanned image-based PDFs will produce no text
- Cold start: The first embedding call after a long idle period may take a few seconds as the model loads into memory
- Groq rate limits: Free tier allows 30 requests/minute and 14,400 requests/day — more than enough for personal use
- Background job queue (Inngest / Trigger.dev) for async processing at scale
- Support for scanned PDFs via OCR (Tesseract.js)
- Multiple file upload in one session
- Document renaming
- Copy-to-clipboard button on AI answers
- Export chat history as PDF or Markdown
- Upgrade to OpenAI embeddings + GPT-4 for higher accuracy
- Full-stack development — Next.js App Router, API routes, server/client components
- RAG architecture — the industry-standard pattern for grounded AI answers
- Vector databases — pgvector, embeddings, cosine similarity search
- Database security — Row Level Security, per-user data isolation
- AI integration — local embedding models, LLM API calls, prompt engineering
- Real product thinking — auth flows, file management, loading states, error handling
MIT — feel free to use this as a starting point for your own projects.