Tutorial: Build a Document Management App
Build a document management application with file upload, automatic classification, RAG-powered search, and an AI chat interface that can answer questions about your documents.
45 minutes -- This tutorial covers object type definition, document CRUD, file upload, AI classification, RAG indexing, and a chat interface for document Q&A.
What You'll Build
- A typed Document object model with metadata fields
- A document list page with filtering by category and status
- A file upload form with the
useDocumentshook - Automatic document classification using AI
- RAG indexing for AI-powered search
- A chat interface that answers questions about uploaded documents
Prerequisites
Before starting this tutorial, make sure you have:
- A working vertical project (see Build a Task Tracker)
- Authenticated with
eai login - Completed the Quick Start
Step 1: Define the Document Object Type
Edit src/eai.config/object-types.ts to add a Document object type. This defines the data model that the platform uses to store and query your documents.
export const objectTypes = {
'doc-manager': [
{
name: 'Document',
displayName: 'Document',
description: 'A managed document with metadata and classification',
properties: [
{ name: 'title', type: 'text', required: true, indexed: true },
{ name: 'description', type: 'text', required: false },
{
name: 'category',
type: 'select',
required: true,
defaultValue: 'general',
options: [
{ label: 'General', value: 'general' },
{ label: 'Contract', value: 'contract' },
{ label: 'Invoice', value: 'invoice' },
{ label: 'Report', value: 'report' },
{ label: 'Policy', value: 'policy' },
],
},
{
name: 'status',
type: 'select',
required: true,
defaultValue: 'draft',
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Under Review', value: 'under-review' },
{ label: 'Approved', value: 'approved' },
{ label: 'Archived', value: 'archived' },
],
},
{ name: 'uploadDate', type: 'date', required: true },
{ name: 'fileId', type: 'text', required: false },
{ name: 'ragIndexed', type: 'boolean', required: false },
],
storageBackend: 'postgresql',
status: 'published',
},
],
};
Each field has a type that determines how the platform validates and stores the data. The select type provides a fixed set of options, while indexed: true on the title field enables fast text lookups.
Step 2: Seed the Object Type
Validate your type definition and push it to the platform:
eai types validate
eai types seed
Verify the type was created:
eai resources list Document --limit 0
This should return an empty result set with no errors, confirming the Document type is registered.
Step 3: Create the Document List Page
Create src/app/(presentation)/documents/page.tsx. This page fetches all documents and displays them in a filterable list.
'use client';
import { useEffect, useState } from 'react';
import { useResources } from '@/hooks/useResources';
interface DocumentData {
title: string;
description?: string;
category: string;
status: string;
uploadDate: string;
fileId?: string;
ragIndexed?: boolean;
}
export default function DocumentsPage() {
const { list } = useResources<DocumentData>('Document');
const [documents, setDocuments] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [filterCategory, setFilterCategory] = useState<string>('all');
useEffect(() => {
list({ page: 1, limit: 50, sort: '-created_at' })
.then((res) => setDocuments(res.docs))
.finally(() => setLoading(false));
}, []);
const categories = ['all', 'general', 'contract', 'invoice', 'report', 'policy'];
const filtered =
filterCategory === 'all'
? documents
: documents.filter((doc) => doc.data.category === filterCategory);
const statusColors: Record<string, string> = {
draft: 'bg-gray-100 text-gray-700',
'under-review': 'bg-yellow-100 text-yellow-700',
approved: 'bg-green-100 text-green-700',
archived: 'bg-red-100 text-red-700',
};
if (loading) return <div className="p-8 text-center">Loading documents...</div>;
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Documents</h1>
<a href="/documents/upload" className="px-4 py-2 bg-primary text-white rounded-md">
Upload Document
</a>
</div>
<div className="flex gap-2 mb-4">
{categories.map((cat) => (
<button
key={cat}
onClick={() => setFilterCategory(cat)}
className={`px-3 py-1 rounded-full text-sm capitalize ${
filterCategory === cat
? 'bg-primary text-white'
: 'bg-muted text-muted-foreground'
}`}
>
{cat}
</button>
))}
</div>
<div className="space-y-3">
{filtered.map((doc) => (
<div key={doc.id} className="border rounded-lg p-4 hover:bg-muted/30">
<div className="flex justify-between items-start">
<div>
<h3 className="font-medium">{doc.data.title}</h3>
{doc.data.description && (
<p className="text-sm text-muted-foreground mt-1">{doc.data.description}</p>
)}
<div className="flex gap-3 mt-2 text-xs text-muted-foreground">
<span className="capitalize">{doc.data.category}</span>
<span>{new Date(doc.data.uploadDate).toLocaleDateString()}</span>
{doc.data.ragIndexed && <span className="text-green-600">RAG Indexed</span>}
</div>
</div>
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[doc.data.status]}`}>
{doc.data.status}
</span>
</div>
</div>
))}
{filtered.length === 0 && (
<p className="text-center text-muted-foreground py-8">
No documents found. Upload your first document to get started.
</p>
)}
</div>
</div>
);
}
Step 4: Create the Upload Form
Create src/app/(presentation)/documents/upload/page.tsx. This form uses the useDocuments hook to handle file uploads and the useResources hook to create the metadata record.
'use client';
import { useState } from 'react';
import { useDocuments } from '@/hooks/useDocuments';
import { useResources } from '@/hooks/useResources';
import { useRouter } from 'next/navigation';
interface DocumentData {
title: string;
description?: string;
category: string;
status: string;
uploadDate: string;
fileId?: string;
ragIndexed?: boolean;
}
export default function UploadDocumentPage() {
const { upload } = useDocuments();
const { create } = useResources<DocumentData>('Document');
const router = useRouter();
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [category, setCategory] = useState('general');
const [file, setFile] = useState<File | null>(null);
const [uploading, setUploading] = useState(false);
const [status, setStatus] = useState('');
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!file) return;
setUploading(true);
try {
setStatus('Uploading file...');
const uploadedDoc = await upload(file);
setStatus('Creating document record...');
await create({
title,
description,
category,
status: 'draft',
uploadDate: new Date().toISOString(),
fileId: uploadedDoc.id,
ragIndexed: false,
});
router.push('/documents');
} catch (error) {
console.error('Upload failed:', error);
setStatus('Upload failed. Please try again.');
} finally {
setUploading(false);
}
}
return (
<div className="p-6 max-w-lg">
<h1 className="text-2xl font-bold mb-6">Upload Document</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Title</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full border rounded-md px-3 py-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full border rounded-md px-3 py-2"
rows={3}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Category</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="w-full border rounded-md px-3 py-2"
>
<option value="general">General</option>
<option value="contract">Contract</option>
<option value="invoice">Invoice</option>
<option value="report">Report</option>
<option value="policy">Policy</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">File</label>
<input
type="file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
accept=".pdf,.docx,.txt,.md,.xlsx"
className="w-full"
required
/>
</div>
{status && <p className="text-sm text-muted-foreground">{status}</p>}
<button
type="submit"
disabled={uploading || !file}
className="px-4 py-2 bg-primary text-white rounded-md disabled:opacity-50"
>
{uploading ? 'Uploading...' : 'Upload Document'}
</button>
</form>
</div>
);
}
Step 5: Add Document Classification
Create src/components/ClassifyButton.tsx. This component uses the classify method from useDocuments to automatically detect the document category using AI.
'use client';
import { useState } from 'react';
import { useDocuments } from '@/hooks/useDocuments';
import { useResources } from '@/hooks/useResources';
interface ClassifyButtonProps {
documentId: string;
fileId: string;
onClassified: (category: string) => void;
}
export function ClassifyButton({ documentId, fileId, onClassified }: ClassifyButtonProps) {
const { classify } = useDocuments();
const { update } = useResources('Document');
const [loading, setLoading] = useState(false);
async function handleClassify() {
setLoading(true);
try {
const result = await classify(fileId, {
categories: ['general', 'contract', 'invoice', 'report', 'policy'],
});
await update(documentId, { category: result.category });
onClassified(result.category);
} catch (error) {
console.error('Classification failed:', error);
} finally {
setLoading(false);
}
}
return (
<button
onClick={handleClassify}
disabled={loading}
className="text-sm px-3 py-1 border rounded-md hover:bg-muted disabled:opacity-50"
>
{loading ? 'Classifying...' : 'Auto-Classify'}
</button>
);
}
Use this component in your document list by adding it inside the card for each document:
<ClassifyButton
documentId={doc.id}
fileId={doc.data.fileId}
onClassified={(category) => {
setDocuments((prev) =>
prev.map((d) => (d.id === doc.id ? { ...d, data: { ...d.data, category } } : d))
);
}}
/>
Step 6: Add RAG Indexing
Create src/components/RagIndexButton.tsx. Once a document is indexed, its content becomes available for AI-powered search and Q&A.
'use client';
import { useState } from 'react';
import { useDocuments } from '@/hooks/useDocuments';
import { useResources } from '@/hooks/useResources';
interface RagIndexButtonProps {
documentId: string;
fileId: string;
indexed: boolean;
onIndexed: () => void;
}
export function RagIndexButton({ documentId, fileId, indexed, onIndexed }: RagIndexButtonProps) {
const { index } = useDocuments();
const { update } = useResources('Document');
const [loading, setLoading] = useState(false);
async function handleIndex() {
setLoading(true);
try {
await index(fileId);
await update(documentId, { ragIndexed: true });
onIndexed();
} catch (error) {
console.error('RAG indexing failed:', error);
} finally {
setLoading(false);
}
}
if (indexed) {
return <span className="text-xs text-green-600">Indexed</span>;
}
return (
<button
onClick={handleIndex}
disabled={loading}
className="text-sm px-3 py-1 border rounded-md hover:bg-muted disabled:opacity-50"
>
{loading ? 'Indexing...' : 'Index for AI Search'}
</button>
);
}
Step 7: Add an AI Chat Interface
Create src/app/(presentation)/documents/chat/page.tsx. This chat page lets users ask questions about their uploaded and indexed documents.
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@/hooks/useChat';
interface Message {
role: 'user' | 'assistant';
content: string;
}
export default function DocumentChatPage() {
const { stream } = useChat();
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [conversationId] = useState(() => crypto.randomUUID());
const endRef = useRef<HTMLDivElement>(null);
useEffect(() => {
endRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
async function handleSend(e: React.FormEvent) {
e.preventDefault();
if (!input.trim() || isStreaming) return;
const userMessage = input;
setInput('');
setMessages((prev) => [...prev, { role: 'user', content: userMessage }]);
setIsStreaming(true);
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
try {
const reader = await stream({
message: userMessage,
conversationId,
params: { useRag: true },
});
let fullContent = '';
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.content) {
fullContent += parsed.content;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = { role: 'assistant', content: fullContent };
return updated;
});
}
} catch {
// Non-JSON SSE line, skip
}
}
}
}
} catch (error) {
console.error('Chat error:', error);
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: 'Something went wrong. Please try again.',
};
return updated;
});
} finally {
setIsStreaming(false);
}
}
return (
<div className="flex flex-col h-[calc(100vh-4rem)]">
<div className="border-b p-4">
<h1 className="text-lg font-bold">Document Q&A</h1>
<p className="text-sm text-muted-foreground">
Ask questions about your indexed documents.
</p>
</div>
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-20">
<p className="text-lg font-medium mb-2">Ask anything about your documents</p>
<p className="text-sm">
Try: "Summarize the latest contract" or "What are the key findings in the Q4 report?"
</p>
</div>
)}
{messages.map((msg, i) => (
<div
key={i}
className={`max-w-[80%] p-4 rounded-lg ${
msg.role === 'user'
? 'ml-auto bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
{msg.content || (isStreaming && i === messages.length - 1 ? '...' : '')}
</div>
))}
<div ref={endRef} />
</div>
<form onSubmit={handleSend} className="border-t p-4 flex gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask about your documents..."
className="flex-1 border rounded-md px-3 py-2"
disabled={isStreaming}
/>
<button
type="submit"
disabled={isStreaming || !input.trim()}
className="px-4 py-2 bg-primary text-white rounded-md disabled:opacity-50"
>
Send
</button>
</form>
</div>
);
}
Verify Your Work
Start the dev server and test each feature:
eai dev
- Open http://localhost:3000/documents -- the list page should load (empty at first)
- Navigate to http://localhost:3000/documents/upload -- upload a PDF or text file
- Back on the list page, click "Auto-Classify" on a document to test AI classification
- Click "Index for AI Search" to add the document to the RAG index
- Open http://localhost:3000/documents/chat and ask a question about the uploaded document
You can also verify from the CLI:
eai resources list Document
eai docs list
eai chat send "Summarize my uploaded documents"
What You Learned
- Object Types: Defining a typed data model with select fields, dates, and booleans
- useResources Hook: CRUD operations for metadata records
- useDocuments Hook: File upload, AI classification, and RAG indexing
- useChat Hook: Building a streaming chat interface with RAG-enabled responses
- Component Composition: Combining multiple hooks to build a full document workflow
Next Steps
- Add AI Chat -- Deep dive into SSE streaming and conversation management
- Create a White-Label Configuration -- Brand your document app for a client
- Configuration Deep Dive -- Advanced tenant configuration