# Receipt Extractor with Next JS

### 1. Introduction

This comprehensive tutorial will guide you through building a full-stack receipt extraction application using Next.js and the JamAI Base TypeScript SDK. The application uses AI to automatically extract key information from receipt images.

#### What We'll Build

An AI-powered web application that allows users to:

1. Upload receipt images through drag-and-drop or file selection
2. Extract information automatically using JamAI Base Action Tables:
   * Shop/Store Name
   * Category (Groceries, Restaurant, Retail, etc.)
   * Total Amount
3. View processing history with pagination
4. Display results in a modern, responsive UI

#### Key Features

* **Modern Next.js 16** with App Router and React Server Components
* **TypeScript** for type safety
* **Tailwind CSS 4** for styling with dark mode support
* **SWR** for efficient data fetching and caching
* **Real-time Processing** with loading states
* **Responsive Design** for mobile and desktop
* **Image Upload** with validation and preview

#### Prerequisites

Before starting, you'll need:

* Node.js 16.x or higher installed
* Basic knowledge of React and TypeScript
* Project ID and Personal Access Token (PAT) from JamAI Base
* A code editor (VS Code recommended)

### 2. Project Setup

#### 2.1 Create Next.js Project

First, create a new Next.js project with TypeScript:

```bash
npx create-next-app@latest receipt-extractor --typescript --tailwind --app --no-src-dir
cd receipt-extractor
```

When prompted, select:

* TypeScript: **Yes**
* ESLint: **Yes**
* Tailwind CSS: **Yes**
* `src/` directory: **Yes**
* App Router: **Yes**
* Import alias: **Yes** (@/\*)

#### 2.2 Install Dependencies

Install the required packages:

```bash
npm install jamaibase swr
```

**Dependencies:**

* `jamaibase` - JamAI Base TypeScript SDK for AI operations
* `swr` - React hooks for data fetching with caching

#### 2.3 Project Structure

Create the following folder structure:

```
receipt-extractor/
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   └── jamai/
│   │   │       ├── upload/
│   │   │       │   └── route.ts
│   │   │       ├── process/
│   │   │       │   └── route.ts
│   │   │       └── history/
│   │   │           └── route.ts
│   │   ├── layout.tsx
│   │   ├── page.tsx
│   │   └── globals.css
│   ├── components/
│   │   ├── ReceiptUpload.tsx
│   │   ├── ReceiptResults.tsx
│   │   ├── ReceiptHistory.tsx
│   │   └── LoadingSpinner.tsx
│   ├── hooks/
│   │   └── useReceipts.ts
│   └── lib/
│       ├── jamai.ts
│       └── types.ts
├── .env.local
├── .env.example
├── package.json
└── tsconfig.json
```

### 3. Getting Your Credentials

#### 3.1 Get Your Personal Access Token (PAT)

1. Go to <https://cloud.jamaibase.com/>
2. Click on your username in the top right
3. Select **Account Settings**
4. Navigate to **Personal Access Token** section
5. Click **Create Personal Access Token**
6. Copy and save your token securely

#### 3.2 Get Your Project ID

1. In the JamAI Base dashboard, navigate to your project
2. Look at the browser URL: `https://cloud.jamaibase.com/project/{PROJECT_ID}`
3. Copy the Project ID from the URL

#### 3.3 Configure Environment Variables

Create a `.env.local` file in your project root:

```bash
# JamAI Base Configuration
# Get your credentials from https://cloud.jamaibase.com/

# Your Personal Access Token (PAT)
JAMAI_API_KEY=your_api_key_here

# Your Project ID
JAMAI_PROJECT_ID=your_project_id_here
```

Also create a `.env.example` file for reference:

```bash
# JamAI Base Configuration
# Get your credentials from https://cloud.jamaibase.com/

# Your Personal Access Token (PAT)
# Get it from: Click user name > Account Settings > Create Personal Access Token
JAMAI_API_KEY=your_api_key_here

# Your Project ID
# Find it in your project URL
JAMAI_PROJECT_ID=your_project_id_here
```

{% hint style="info" %}
**Important:** Never commit `.env.local` to version control. Add it to your `.gitignore` file.
{% endhint %}

### 4. Core Configuration

#### 4.1 Create Type Definitions

Create `src/lib/types.ts`:

```typescript
// Receipt data structure
export interface Receipt {
  id: string;
  shopName: string;
  category: string;
  total: string;
  imageUrl: string;
  createdAt: string;
  updatedAt: string;
}

// API response types
export interface UploadResponse {
  uri: string;
}

export interface ProcessResponse {
  success: boolean;
  receipt: Receipt;
}

export interface HistoryResponse {
  receipts: Receipt[];
  total: number;
  offset: number;
  limit: number;
}

export interface ErrorResponse {
  error: string;
  details?: string;
}

// Helper function to extract value from JamAI wrapped field
// JamAI listRows returns: { 'Shop Name': { value: 'Walmart' } }
// This function unwraps to: 'Walmart'
export function getValue(field: any): any {
  if (field && typeof field === "object" && "value" in field) {
    return field.value;
  }
  return field;
}

// Helper function to unwrap a full row from JamAI listRows response
export function unwrapRow(row: Record<string, any>): Receipt {
  return {
    id: getValue(row["ID"]) || getValue(row["Row ID"]) || "",
    shopName: getValue(row["Shop Name"]) || "",
    category: getValue(row["Category"]) || "",
    total: getValue(row["Total"]) || "",
    imageUrl: getValue(row["Image"]) || "",
    createdAt:
      getValue(row["Created at"]) ||
      getValue(row["created_at"]) ||
      new Date().toISOString(),
    updatedAt:
      getValue(row["Updated at"]) ||
      getValue(row["updated_at"]) ||
      new Date().toISOString(),
  };
}
```

#### 4.2 Initialize JamAI Client

Create `src/lib/jamai.ts`:

```typescript
import JamAI from "jamaibase";

// Singleton JamAI client instance
let jamaiClient: JamAI | null = null;

/**
 * Get or create the JamAI client instance
 * This ensures we only create one client throughout the application lifecycle
 */
export function getJamaiClient(): JamAI {
  if (!jamaiClient) {
    // Validate environment variables
    const apiKey = process.env.JAMAI_API_KEY;
    const projectId = process.env.JAMAI_PROJECT_ID;

    if (!apiKey || !projectId) {
      throw new Error(
        "Missing JamAI credentials. Please set JAMAI_API_KEY and JAMAI_PROJECT_ID in .env.local"
      );
    }

    // Create client instance
    jamaiClient = new JamAI({
      token: apiKey,
      projectId: projectId,
    });
  }

  return jamaiClient;
}

/**
 * Ensure the "receipt" action table exists
 * Creates it if it doesn't exist with proper column configuration
 */
export async function ensureTableExists(): Promise<void> {
  const client = getJamaiClient();

  try {
    // Check if table already exists
    const tables = await client.table.listTables({
      table_type: "action",
    });

    const tableExists = tables.items.some((t) => t.id === "receipt");

    if (!tableExists) {
      console.log('Creating "receipt" action table...');

      // Create the action table with proper schema
      await client.table.createActionTable({
        id: "receipt",
        cols: [
          // Input column for the image file
          {
            id: "Image",
            dtype: "image",
          },
          // LLM output column for shop name
          {
            id: "Shop Name",
            dtype: "str",
            gen_config: {
              object: "gen_config.llm",
              model: "ellm/Qwen/Qwen3-VL-30B-A3B-Instruct",
              system_prompt:
                "You are a receipt data extraction assistant. Extract information accurately from receipt images.",
              prompt:
                "Table name: \"receipt\"\n\nImage: ${Image}\n\nExtract the shop or store name from this receipt image. Return only the name, nothing else. If you cannot find a shop name, return 'Unknown'.",
              temperature: 0.1,
              max_tokens: 100,
            },
          },
          // LLM output column for category
          {
            id: "Category",
            dtype: "str",
            gen_config: {
              object: "gen_config.llm",
              model: "ellm/Qwen/Qwen3-VL-30B-A3B-Instruct",
              system_prompt:
                "You are a receipt categorization assistant. Categorize receipts accurately.",
              prompt:
                'Table name: "receipt"\n\nImage: ${Image}\n\nCategorize this receipt into one of the following categories: Groceries, Restaurant, Retail, Gas, Entertainment, Healthcare, or Other. Return only the category name.',
              temperature: 0.1,
              max_tokens: 50,
            },
          },
          // LLM output column for total amount
          {
            id: "Total",
            dtype: "str",
            gen_config: {
              object: "gen_config.llm",
              model: "ellm/Qwen/Qwen3-VL-30B-A3B-Instruct",
              system_prompt:
                "You are a receipt data extraction assistant. Extract information accurately from receipt images.",
              prompt:
                "Table name: \"receipt\"\n\nImage: ${Image}\n\nExtract the total amount from this receipt. Include the currency symbol. Return only the amount (e.g., '$45.99' or '€32.50'). If you cannot find a total, return 'N/A'.",
              temperature: 0.1,
              max_tokens: 50,
            },
          },
        ],
      });

      console.log('Successfully created "receipt" table');
    } else {
      console.log('"receipt" table already exists');
    }
  } catch (error) {
    console.error("Error ensuring table exists:", error);
    throw new Error("Failed to initialize receipt table");
  }
}
```

**Key Points:**

* Uses singleton pattern for the JamAI client
* Validates environment variables on initialization
* Automatically creates the Action Table if it doesn't exist
* Configures three LLM output columns with specific prompts
* Uses Qwen3-VL-30B (embedded LLM) for cost-effective, fast processing
* Prompts include table context and use `${Image}` variable reference

### 5. Building the API Routes

#### 5.1 File Upload Route

Create `src/app/api/jamai/upload/route.ts`:

```typescript
import { NextResponse } from "next/server";
import { getJamaiClient } from "@/lib/jamai";

// Allowed image MIME types
const ALLOWED_TYPES = [
  "image/jpeg",
  "image/jpg",
  "image/png",
  "image/webp",
  "image/gif",
];

// Maximum file size: 10MB
const MAX_FILE_SIZE = 10 * 1024 * 1024;

export async function POST(request: Request) {
  try {
    // Parse form data
    const formData = await request.formData();
    const file = formData.get("file") as File | null;

    if (!file) {
      return NextResponse.json({ error: "File is required" }, { status: 400 });
    }

    // Validate file type
    if (!ALLOWED_TYPES.includes(file.type)) {
      return NextResponse.json(
        {
          error: "Invalid file type",
          details: `Allowed types: ${ALLOWED_TYPES.join(", ")}`,
        },
        { status: 400 }
      );
    }

    // Validate file size
    if (file.size > MAX_FILE_SIZE) {
      return NextResponse.json(
        {
          error: "File too large",
          details: `Maximum file size is ${MAX_FILE_SIZE / 1024 / 1024}MB`,
        },
        { status: 413 }
      );
    }

    // Upload to JamAI Base, passing the File directly
    const client = getJamaiClient();
    const fileResponse = await client.file.uploadFile({
      file, // pass the File object directly
    });

    // Return the JamAI file URI
    return NextResponse.json({
      uri: fileResponse.uri,
    });
  } catch (error) {
    console.error("[Upload API Error]:", error);

    if (error instanceof Error) {
      return NextResponse.json(
        {
          error: "Upload failed",
          details: error.message,
        },
        { status: 500 }
      );
    }

    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}
```

**What this does:**

* Accepts multipart form data with an image file
* Validates file type (images only) and size (10MB max)
* Uploads the file to JamAI Base storage
* Returns a URI for the uploaded file

#### 5.2 Receipt Processing Route

Create `src/app/api/jamai/process/route.ts`:

```typescript
import { NextResponse } from "next/server";
import { getJamaiClient, ensureTableExists } from "@/lib/jamai";
import type { ProcessResponse } from "@/lib/types";

export async function POST(request: Request) {
  try {
    // Parse JSON body
    const body = await request.json();
    const { imageUri } = body;

    if (!imageUri || typeof imageUri !== "string") {
      return NextResponse.json(
        { error: "imageUri is required and must be a string" },
        { status: 400 }
      );
    }

    // Ensure the receipt table exists
    await ensureTableExists();

    // Get JamAI client
    const client = getJamaiClient();

    // Process the receipt by adding a row to the action table
    const response = await client.table.addRow({
      table_type: "action",
      table_id: "receipt",
      data: [
        {
          Image: imageUri,
        },
      ],
      concurrent: false, // Process synchronously for immediate results
    });

    // Extract the first row from the response
    const row = response.rows[0];

    if (!row) {
      throw new Error("No row returned from JamAI Base");
    }

    // Extract the LLM-generated outputs from the columns
    const shopName =
      row.columns["Shop Name"]?.choices?.[0]?.message?.content || "Unknown";
    const category =
      row.columns["Category"]?.choices?.[0]?.message?.content || "Other";
    const total = row.columns["Total"]?.choices?.[0]?.message?.content || "N/A";

    // Get the row ID for future reference
    const rowId = row.row_id || "";

    // Construct the receipt object
    const receipt = {
      id: rowId,
      shopName: String(shopName).trim(),
      category: String(category).trim(),
      total: String(total).trim(),
      imageUrl: imageUri,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };

    const result: ProcessResponse = {
      success: true,
      receipt,
    };

    return NextResponse.json(result);
  } catch (error) {
    console.error("[Process API Error]:", error);

    if (error instanceof Error) {
      return NextResponse.json(
        {
          error: "Processing failed",
          details: error.message,
        },
        { status: 500 }
      );
    }

    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}
```

**What this does:**

* Receives the uploaded image URI
* Ensures the Action Table exists
* Adds a row to the table with the image
* JamAI Base automatically processes the image through LLMs
* Extracts the AI-generated outputs (shop name, category, total)
* Returns the structured receipt data

#### 5.3 History Retrieval Route

Create `src/app/api/jamai/history/route.ts`:

```typescript
import { NextResponse } from "next/server";
import { getJamaiClient } from "@/lib/jamai";
import { unwrapRow, type HistoryResponse } from "@/lib/types";

export async function GET(request: Request) {
  try {
    // Parse query parameters
    const { searchParams } = new URL(request.url);
    const offset = parseInt(searchParams.get("offset") || "0", 10);
    const limit = parseInt(searchParams.get("limit") || "50", 10);

    // Validate parameters
    if (offset < 0 || limit < 1 || limit > 100) {
      return NextResponse.json(
        {
          error: "Invalid parameters",
          details: "offset must be >= 0, limit must be between 1 and 100",
        },
        { status: 400 }
      );
    }

    // Get JamAI client
    const client = getJamaiClient();

    // Fetch rows from the receipt table
    const response = await client.table.listRows({
      table_type: "action",
      table_id: "receipt",
      offset,
      limit,
    });

    if (response?.items?.length > 0) {
      // Unwrap the values from each row
      // CRITICAL: JamAI wraps values like { 'Shop Name': { value: 'Walmart' } }
      const receipts = response.items?.map((row) => unwrapRow(row));

      const result: HistoryResponse = {
        receipts,
        total: response.total || response.items?.length,
        offset: response.offset || offset,
        limit: response.limit || limit,
      };

      return NextResponse.json(result);
    }
    const emptyResponse: HistoryResponse = {
      receipts: [],
      total: 0,
      offset: 0,
      limit,
    };
    return NextResponse.json(emptyResponse);
  } catch (error) {
    console.error("[History API Error]:", error);

    if (error instanceof Error) {
      // If it's a "table not found" error, return empty results
      if (
        error.message.includes("not found") ||
        error.message.includes("does not exist")
      ) {
        const emptyResponse: HistoryResponse = {
          receipts: [],
          total: 0,
          offset: 0,
          limit: 50,
        };
        return NextResponse.json(emptyResponse);
      }

      return NextResponse.json(
        {
          error: "Failed to fetch history",
          details: error.message,
        },
        { status: 500 }
      );
    }

    return NextResponse.json(
      { error: "Internal server error" },
      { status: 500 }
    );
  }
}
```

**What this does:**

* Accepts pagination parameters (offset, limit)
* Fetches historical receipts from the Action Table
* Unwraps JamAI's nested data structure
* Returns paginated results
* Handles the case when the table doesn't exist yet

### 6. Building the UI Components

#### 6.1 Loading Spinner Component

Create `src/components/LoadingSpinner.tsx`:

```typescript
interface LoadingSpinnerProps {
  size?: "sm" | "md" | "lg";
}

export function LoadingSpinner({ size = "md" }: LoadingSpinnerProps) {
  const sizeClasses = {
    sm: "w-4 h-4",
    md: "w-8 h-8",
    lg: "w-12 h-12",
  };

  return (
    <div className={`${sizeClasses[size]} animate-spin`}>
      <svg
        className="w-full h-full text-zinc-600 dark:text-zinc-400"
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
      >
        <circle
          className="opacity-25"
          cx="12"
          cy="12"
          r="10"
          stroke="currentColor"
          strokeWidth="4"
        />
        <path
          className="opacity-75"
          fill="currentColor"
          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
        />
      </svg>
    </div>
  );
}
```

#### 6.2 Receipt Upload Component

Create `src/components/ReceiptUpload.tsx`:

```typescript
"use client";

import { useState, useRef } from "react";
import type { Receipt } from "@/lib/types";
import { LoadingSpinner } from "./LoadingSpinner";

interface ReceiptUploadProps {
  onUploadComplete: (receipt: Receipt) => void;
  onError: (error: string) => void;
}

type ProgressState = "idle" | "uploading" | "processing";

export function ReceiptUpload({
  onUploadComplete,
  onError,
}: ReceiptUploadProps) {
  const [isDragging, setIsDragging] = useState(false);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [selectedFile, setSelectedFile] = useState<File | null>(null);
  const [isProcessing, setIsProcessing] = useState(false);
  const [progress, setProgress] = useState<ProgressState>("idle");
  const fileInputRef = useRef<HTMLInputElement>(null);

  const validateFile = (file: File): boolean => {
    // Validate file type
    if (!file.type.startsWith("image/")) {
      onError("Please upload an image file (JPEG, PNG, WebP, or GIF)");
      return false;
    }

    // Validate file size (10MB max)
    const maxSize = 10 * 1024 * 1024;
    if (file.size > maxSize) {
      onError("File size must be less than 10MB");
      return false;
    }

    return true;
  };

  const handleFile = (file: File) => {
    if (!validateFile(file)) {
      return;
    }

    setSelectedFile(file);
    const url = URL.createObjectURL(file);
    setPreviewUrl(url);
  };

  const handleDragEnter = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(true);
  };

  const handleDragOver = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
  };

  const handleDragLeave = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);
  };

  const handleDrop = (e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    setIsDragging(false);

    const files = e.dataTransfer.files;
    if (files && files[0]) {
      handleFile(files[0]);
    }
  };

  const handleClick = () => {
    fileInputRef.current?.click();
  };

  const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
    const files = e.target.files;
    if (files && files[0]) {
      handleFile(files[0]);
    }
  };

  const handleProcess = async () => {
    if (!selectedFile) return;

    setIsProcessing(true);
    setProgress("uploading");

    try {
      // Step 1: Upload file
      const formData = new FormData();
      formData.append("file", selectedFile);

      const uploadRes = await fetch("/api/jamai/upload", {
        method: "POST",
        body: formData,
      });

      if (!uploadRes.ok) {
        const errorData = await uploadRes.json();
        throw new Error(errorData.error || "Upload failed");
      }

      const { uri } = await uploadRes.json();

      // Step 2: Process receipt
      setProgress("processing");
      const processRes = await fetch("/api/jamai/process", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ imageUri: uri }),
      });

      if (!processRes.ok) {
        const errorData = await processRes.json();
        throw new Error(errorData.error || "Processing failed");
      }

      const { receipt } = await processRes.json();

      // Success! Notify parent and reset
      onUploadComplete(receipt);

      // Cleanup
      if (previewUrl) {
        URL.revokeObjectURL(previewUrl);
      }
      setPreviewUrl(null);
      setSelectedFile(null);
      setProgress("idle");

      // Reset file input
      if (fileInputRef.current) {
        fileInputRef.current.value = "";
      }
    } catch (error) {
      const message =
        error instanceof Error ? error.message : "An error occurred";
      onError(message);
      setProgress("idle");
    } finally {
      setIsProcessing(false);
    }
  };

  const handleClear = () => {
    if (previewUrl) {
      URL.revokeObjectURL(previewUrl);
    }
    setPreviewUrl(null);
    setSelectedFile(null);
    if (fileInputRef.current) {
      fileInputRef.current.value = "";
    }
  };

  const getProgressText = () => {
    switch (progress) {
      case "uploading":
        return "Uploading image...";
      case "processing":
        return "Extracting receipt data...";
      default:
        return "";
    }
  };

  return (
    <div className="w-full">
      {!previewUrl ? (
        // Upload zone
        <div
          className={`
            relative border-2 border-dashed rounded-lg p-12 text-center
            transition-colors cursor-pointer
            ${
              isDragging
                ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
                : "border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600"
            }
          `}
          onDragEnter={handleDragEnter}
          onDragOver={handleDragOver}
          onDragLeave={handleDragLeave}
          onDrop={handleDrop}
          onClick={handleClick}
        >
          <input
            ref={fileInputRef}
            type="file"
            accept="image/*"
            onChange={handleFileInput}
            className="hidden"
          />

          <div className="flex flex-col items-center gap-4">
            <svg
              className="w-16 h-16 text-zinc-400 dark:text-zinc-600"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
              />
            </svg>

            <div>
              <p className="text-lg font-medium text-zinc-900 dark:text-zinc-100">
                Drag and drop receipt image
              </p>
              <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
                or click to select a file
              </p>
            </div>

            <p className="text-xs text-zinc-400 dark:text-zinc-600">
              Supports JPEG, PNG, WebP, GIF (max 10MB)
            </p>
          </div>
        </div>
      ) : (
        // Preview and process
        <div className="bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 p-6">
          <div className="flex flex-col md:flex-row gap-6">
            {/* Image preview */}
            <div className="shrink-0">
              <img
                src={previewUrl}
                alt="Receipt preview"
                className="w-full md:w-48 h-auto max-h-64 object-contain rounded border border-zinc-200 dark:border-zinc-700"
              />
            </div>

            {/* Actions */}
            <div className="flex-1 flex flex-col justify-between">
              <div>
                <h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100 mb-2">
                  Ready to process
                </h3>
                <p className="text-sm text-zinc-600 dark:text-zinc-400 mb-4">
                  Click the button below to extract receipt information
                </p>

                {isProcessing && (
                  <div className="flex items-center gap-3 text-sm text-zinc-600 dark:text-zinc-400 mb-4">
                    <LoadingSpinner size="sm" />
                    <span>{getProgressText()}</span>
                  </div>
                )}
              </div>

              <div className="flex gap-3">
                <button
                  onClick={handleProcess}
                  disabled={isProcessing}
                  className="flex-1 bg-zinc-900 dark:bg-zinc-100 text-zinc-50 dark:text-zinc-900 px-6 py-3 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                >
                  {isProcessing ? "Processing..." : "Process Receipt"}
                </button>
                <button
                  onClick={handleClear}
                  disabled={isProcessing}
                  className="px-6 py-3 rounded-lg font-medium border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
                >
                  Clear
                </button>
              </div>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}
```

**Key Features:**

* Drag-and-drop file upload
* Click to select file
* Image preview before processing
* Client-side validation
* Visual loading states with progress messages
* Error handling

#### 6.3 Receipt Results Component

Create `src/components/ReceiptResults.tsx`:

```typescript
"use client";

import type { Receipt } from "@/lib/types";

interface ReceiptResultsProps {
  receipt: Receipt | null;
  onClear: () => void;
}

interface FieldProps {
  label: string;
  value: string;
  highlighted?: boolean;
}

function Field({ label, value, highlighted = false }: FieldProps) {
  return (
    <div className="space-y-1">
      <p className="text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wide">
        {label}
      </p>
      <p
        className={`font-medium ${
          highlighted
            ? "text-2xl text-zinc-900 dark:text-zinc-50"
            : "text-lg text-zinc-800 dark:text-zinc-100"
        }`}
      >
        {value}
      </p>
    </div>
  );
}

export function ReceiptResults({ receipt, onClear }: ReceiptResultsProps) {
  if (!receipt) return null;

  return (
    <div className="bg-white dark:bg-zinc-900 rounded-lg shadow-lg p-6 border border-zinc-200 dark:border-zinc-800 animate-in fade-in duration-300">
      <div className="flex items-start justify-between mb-6">
        <div>
          <h3 className="text-xl font-bold text-zinc-900 dark:text-zinc-50">
            Extraction Results
          </h3>
          <p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
            Successfully extracted receipt information
          </p>
        </div>
        <div className="flex items-center justify-center w-10 h-10 rounded-full bg-green-100 dark:bg-green-900/30">
          <svg
            className="w-6 h-6 text-green-600 dark:text-green-400"
            fill="none"
            stroke="currentColor"
            viewBox="0 0 24 24"
          >
            <path
              strokeLinecap="round"
              strokeLinejoin="round"
              strokeWidth={2}
              d="M5 13l4 4L19 7"
            />
          </svg>
        </div>
      </div>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
        <Field label="Shop Name" value={receipt.shopName} />
        <Field label="Category" value={receipt.category} />
        <Field label="Total Amount" value={receipt.total} highlighted />
      </div>

      <div className="flex gap-3 pt-4 border-t border-zinc-200 dark:border-zinc-800">
        <button
          onClick={onClear}
          className="flex-1 bg-zinc-900 dark:bg-zinc-100 text-zinc-50 dark:text-zinc-900 px-6 py-3 rounded-lg font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
        >
          Process Another Receipt
        </button>
      </div>
    </div>
  );
}
```

#### 6.4 Receipt History Component

Create `src/components/ReceiptHistory.tsx`:

```typescript
"use client";

import { useState, useEffect } from "react";
import useSWR from "swr";
import type { HistoryResponse } from "@/lib/types";
import { LoadingSpinner } from "./LoadingSpinner";

const fetcher = (url: string) => fetch(url).then((res) => res.json());

interface ReceiptHistoryProps {
  refreshTrigger?: number;
}

export function ReceiptHistory({ refreshTrigger }: ReceiptHistoryProps) {
  const [offset, setOffset] = useState(0);
  const limit = 50;

  const { data, error, isLoading, mutate } = useSWR<HistoryResponse>(
    `/api/jamai/history?offset=${offset}&limit=${limit}`,
    fetcher,
    {
      revalidateOnFocus: false,
      revalidateOnReconnect: true,
    }
  );

  // Refetch data when refreshTrigger changes (after new receipt is processed)
  useEffect(() => {
    if (refreshTrigger !== undefined && refreshTrigger > 0) {
      mutate();
    }
  }, [refreshTrigger, mutate]);

  const handlePrevious = () => {
    setOffset(Math.max(0, offset - limit));
  };

  const handleNext = () => {
    if (data && offset + limit < data.total) {
      setOffset(offset + limit);
    }
  };

  if (isLoading) {
    return (
      <div className="flex items-center justify-center py-12">
        <LoadingSpinner size="lg" />
      </div>
    );
  }

  if (error) {
    return (
      <div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
        <p className="text-red-900 dark:text-red-200 text-sm">
          Failed to load history. Please try again.
        </p>
      </div>
    );
  }

  if (!data || data.receipts?.length === 0) {
    return (
      <div className="text-center py-12 bg-zinc-50 dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800">
        <svg
          className="w-16 h-16 text-zinc-300 dark:text-zinc-700 mx-auto mb-4"
          fill="none"
          stroke="currentColor"
          viewBox="0 0 24 24"
        >
          <path
            strokeLinecap="round"
            strokeLinejoin="round"
            strokeWidth={2}
            d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
          />
        </svg>
        <h3 className="text-lg font-medium text-zinc-900 dark:text-zinc-100 mb-1">
          No receipts yet
        </h3>
        <p className="text-sm text-zinc-500 dark:text-zinc-400">
          Upload your first receipt to get started
        </p>
      </div>
    );
  }

  return (
    <div className="space-y-4">
      {/* Desktop Table View */}
      <div className="hidden md:block bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 overflow-hidden">
        <table className="w-full">
          <thead className="bg-zinc-50 dark:bg-zinc-800 border-b border-zinc-200 dark:border-zinc-700">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
                Shop Name
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
                Category
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
                Total
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">
                Date
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
            {data.receipts?.map((receipt) => (
              <tr
                key={receipt.id}
                className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors"
              >
                <td className="px-6 py-4 whitespace-nowrap">
                  <div className="text-sm font-medium text-zinc-900 dark:text-zinc-100">
                    {receipt.shopName}
                  </div>
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
                    {receipt.category}
                  </span>
                </td>
                <td className="px-6 py-4 whitespace-nowrap">
                  <div className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
                    {receipt.total}
                  </div>
                </td>
                <td className="px-6 py-4 whitespace-nowrap text-sm text-zinc-500 dark:text-zinc-400">
                  {new Date(receipt.updatedAt).toLocaleDateString()}
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* Mobile Card View */}
      <div className="md:hidden space-y-3">
        {data.receipts?.map((receipt) => (
          <div
            key={receipt.id}
            className="bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 p-4"
          >
            <div className="flex items-start justify-between mb-3">
              <div>
                <h4 className="font-medium text-zinc-900 dark:text-zinc-100">
                  {receipt.shopName}
                </h4>
                <p className="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
                  {new Date(receipt.updatedAt).toLocaleDateString()}
                </p>
              </div>
              <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200">
                {receipt.category}
              </span>
            </div>
            <div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
              {receipt.total}
            </div>
          </div>
        ))}
      </div>

      {/* Pagination */}
      {data.total > limit && (
        <div className="flex items-center justify-between bg-white dark:bg-zinc-900 rounded-lg border border-zinc-200 dark:border-zinc-800 px-4 py-3">
          <div className="text-sm text-zinc-600 dark:text-zinc-400">
            Showing {offset + 1}-{Math.min(offset + limit, data.total)} of{" "}
            {data.total}
          </div>
          <div className="flex gap-2">
            <button
              onClick={handlePrevious}
              disabled={offset === 0}
              className="px-4 py-2 text-sm font-medium rounded-lg border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
            >
              Previous
            </button>
            <button
              onClick={handleNext}
              disabled={offset + limit >= data.total}
              className="px-4 py-2 text-sm font-medium rounded-lg border border-zinc-300 dark:border-zinc-700 text-zinc-900 dark:text-zinc-100 hover:bg-zinc-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
            >
              Next
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
```

**Key Features:**

* Uses SWR for data fetching with caching
* Auto-refreshes when new receipts are added
* Responsive design (table on desktop, cards on mobile)
* Pagination support
* Empty state handling

#### 6.5 Custom Hook for Data Fetching

Create `src/hooks/useReceipts.ts`:

```typescript
import useSWR from "swr";
import type { HistoryResponse } from "@/lib/types";

const fetcher = (url: string) => fetch(url).then((res) => res.json());

interface UseReceiptsOptions {
  offset?: number;
  limit?: number;
}

export function useReceipts({
  offset = 0,
  limit = 50,
}: UseReceiptsOptions = {}) {
  const { data, error, mutate, isLoading } = useSWR<HistoryResponse>(
    `/api/jamai/history?offset=${offset}&limit=${limit}`,
    fetcher,
    {
      revalidateOnFocus: false,
      revalidateOnReconnect: true,
    }
  );

  return {
    receipts: data?.receipts || [],
    total: data?.total || 0,
    offset: data?.offset || offset,
    limit: data?.limit || limit,
    isLoading,
    error,
    mutate, // For manual refresh after upload
  };
}
```

### 7. Building the Main Page

#### 7.1 Update Layout

Update `src/app/layout.tsx`:

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Receipt Extraction App",
  description:
    "AI-powered receipt extraction using JamAI Base. Extract shop names, categories, and totals from receipt images.",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}
```

#### 7.2 Create Main Page

Update `src/app/page.tsx`:

```typescript
"use client";

import { useState } from "react";
import { ReceiptUpload } from "@/components/ReceiptUpload";
import { ReceiptResults } from "@/components/ReceiptResults";
import { ReceiptHistory } from "@/components/ReceiptHistory";
import type { Receipt } from "@/lib/types";

export default function Home() {
  const [currentReceipt, setCurrentReceipt] = useState<Receipt | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [refreshTrigger, setRefreshTrigger] = useState(0);

  const handleUploadComplete = (receipt: Receipt) => {
    setCurrentReceipt(receipt);
    setError(null);
    // Trigger history refresh
    setRefreshTrigger((prev) => prev + 1);
  };

  const handleError = (errorMessage: string) => {
    setError(errorMessage);
    // Auto-dismiss error after 5 seconds
    setTimeout(() => setError(null), 5000);
  };

  const handleClear = () => {
    setCurrentReceipt(null);
  };

  return (
    <div className="min-h-screen bg-zinc-50 dark:bg-black">
      <main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
        {/* Header */}
        <header className="mb-12">
          <h1 className="text-4xl font-bold text-zinc-900 dark:text-zinc-50 mb-2">
            Receipt Extraction
          </h1>
          <p className="text-lg text-zinc-600 dark:text-zinc-400">
            Upload receipts to extract shop name, category, and total amount
            using AI
          </p>
        </header>

        {/* Error Toast */}
        {error && (
          <div className="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 animate-in fade-in duration-200">
            <div className="flex items-start gap-3">
              <svg
                className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
                />
              </svg>
              <div className="flex-1">
                <h3 className="text-sm font-medium text-red-900 dark:text-red-200">
                  Error
                </h3>
                <p className="text-sm text-red-800 dark:text-red-300 mt-1">
                  {error}
                </p>
              </div>
              <button
                onClick={() => setError(null)}
                className="text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-200"
              >
                <svg
                  className="w-5 h-5"
                  fill="none"
                  stroke="currentColor"
                  viewBox="0 0 24 24"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M6 18L18 6M6 6l12 12"
                  />
                </svg>
              </button>
            </div>
          </div>
        )}

        {/* Upload Section */}
        <section className="mb-12">
          <h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-4">
            Upload Receipt
          </h2>
          <ReceiptUpload
            onUploadComplete={handleUploadComplete}
            onError={handleError}
          />
        </section>

        {/* Results Section */}
        {currentReceipt && (
          <section className="mb-12">
            <h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-4">
              Latest Result
            </h2>
            <ReceiptResults receipt={currentReceipt} onClear={handleClear} />
          </section>
        )}

        {/* History Section */}
        <section>
          <h2 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50 mb-4">
            Processing History
          </h2>
          <ReceiptHistory refreshTrigger={refreshTrigger} />
        </section>
      </main>
    </div>
  );
}
```

### 8. Running the Application

#### 8.1 Start Development Server

```bash
npm run dev
```

The application will start at <http://localhost:3000>

#### 8.2 Test the Application

1. **Upload a Receipt**
   * Drag and drop a receipt image, or click to select a file
   * Supported formats: JPEG, PNG, WebP, GIF (max 10MB)
2. **Process Receipt**
   * Click "Process Receipt" button
   * Watch the progress indicators:
     * "Uploading image..."
     * "Extracting receipt data..."
3. **View Results**
   * See extracted information:
     * Shop Name
     * Category
     * Total Amount
4. **Check History**
   * Scroll down to see all processed receipts
   * Navigate through pages if you have more than 50 receipts

#### 8.3 First Run

On the first API call, the application will automatically:

1. Connect to JamAI Base using your credentials
2. Create the "receipt" Action Table if it doesn't exist
3. Configure the LLM columns with extraction prompts

Check your terminal logs to see:

```
Creating "receipt" action table...
Successfully created "receipt" table
```

### 9. How It Works

#### 9.1 Architecture Overview

```
┌─────────────┐
│   Browser   │
│  (Next.js)  │
└──────┬──────┘
       │
       │ 1. Upload Image
       ↓
┌─────────────┐
│ Upload API  │
│  Route      │
└──────┬──────┘
       │
       │ 2. Store in JamAI
       ↓
┌─────────────┐
│  JamAI Base │
│ File Storage│
└──────┬──────┘
       │
       │ 3. Get File URI
       ↓
┌─────────────┐
│ Process API │
│   Route     │
└──────┬──────┘
       │
       │ 4. Add Row to Action Table
       ↓
┌─────────────┐
│  JamAI Base │
│Action Table │
└──────┬──────┘
       │
       │ 5. LLM Processing
       ↓
┌─────────────┐
│  Qwen3-VL   │
│    30B      │
└──────┬──────┘
       │
       │ 6. Return Results
       ↓
┌─────────────┐
│   Browser   │
│  (Display)  │
└─────────────┘
```

#### 9.2 Data Flow

1. **Upload Phase**
   * User selects/drops an image file
   * Client validates file type and size
   * FormData sent to `/api/jamai/upload`
   * Image stored in JamAI Base, returns URI
2. **Processing Phase**
   * Client sends image URI to `/api/jamai/process`
   * API ensures Action Table exists
   * Row added to table with image URI
   * JamAI Base triggers LLM processing:
     * Qwen3-VL-30B (embedded LLM) analyzes the image
     * Three separate prompts extract different fields
     * Results stored in table columns
3. **Display Phase**
   * API returns structured receipt data
   * Client displays results
   * History automatically refreshes

#### 9.3 JamAI Base Action Table Structure

The "receipt" table has the following schema:

| Column Name | Type             | Description                |
| ----------- | ---------------- | -------------------------- |
| Image       | Input (image)    | Receipt image file         |
| Shop Name   | LLM Output (str) | Extracted store name       |
| Category    | LLM Output (str) | Receipt category           |
| Total       | LLM Output (str) | Total amount with currency |

Each LLM Output column has:

* **Model:** ellm/Qwen/Qwen3-VL-30B-A3B-Instruct (embedded vision LLM)
* **Temperature:** 0.1 (for consistency)
* **Custom Prompts:** Specialized for each field with table context and variable references

### 10. Customization

#### 10.1 Modify Extraction Prompts

To change what information is extracted, edit the prompts in src/lib/jamai.ts:

```typescript
// Example: Extract date instead of shop name
{
  id: "Date",
  dtype: "str",
  gen_config: {
    object: "gen_config.llm",
    model: "ellm/Qwen/Qwen3-VL-30B-A3B-Instruct",
    system_prompt: "You are a receipt data extraction assistant.",
    prompt: "Table name: \"receipt\"\n\nImage: ${Image}\n\nExtract the date from this receipt. Format as YYYY-MM-DD.",
    temperature: 0.1,
    max_tokens: 50,
  },
}
```

#### 10.2 Add New Fields

To extract additional information:

1. Add the column definition in `ensureTableExists()`:

```typescript
{
  id: "Tax Amount",
  dtype: "str",
  gen_config: {
    object: "gen_config.llm",
    model: "ellm/Qwen/Qwen3-VL-30B-A3B-Instruct",
    system_prompt: "You are a receipt data extraction assistant.",
    prompt: "Table name: \"receipt\"\n\nImage: ${Image}\n\nExtract the tax amount from this receipt. Include currency symbol.",
    temperature: 0.1,
    max_tokens: 50,
  },
}
```

2. Update the `Receipt` interface in src/lib/types.ts:

```typescript
export interface Receipt {
  id: string;
  shopName: string;
  category: string;
  total: string;
  taxAmount: string; // Add new field
  imageUrl: string;
  createdAt: string;
  updatedAt: string;
}
```

3. Update extraction in the process route
4. Update UI components to display the new field

#### 10.3 Change LLM Model

You can use different models for different accuracy and cost trade-offs:

```typescript
gen_config: {
  object: "gen_config.llm",
  model: "ellm/Qwen/Qwen3-VL-30B-A3B-Instruct",  // Default: Fast embedded vision LLM
  // or
  model: "openai/gpt-4o",  // More accurate but more expensive
  // or
  model: "anthropic/claude-3-5-sonnet-20241022",  // Claude Sonnet
  // ...
}
```

**Note:** When using different models, remember to include proper context in prompts. For vision models, use the `${Image}` variable reference as shown in the examples.

#### 10.4 Styling Customization

The app uses Tailwind CSS. Customize colors, spacing, and styles:

```typescript
// Example: Change primary button color
className = "bg-blue-600 hover:bg-blue-700 text-white ...";
```

### 11. Deployment

#### 11.1 Deploy to Vercel

1. **Push to GitHub**

   ```bash
   git init
   git add .
   git commit -m "Initial commit"
   git remote add origin YOUR_REPO_URL
   git push -u origin main
   ```
2. **Deploy on Vercel**
   * Go to [vercel.com](https://vercel.com)
   * Click "Import Project"
   * Select your GitHub repository
   * Add environment variables:
     * `JAMAI_API_KEY`
     * `JAMAI_PROJECT_ID`
   * Click "Deploy"
3. **Verify Deployment**
   * Visit your deployment URL
   * Test receipt upload and processing

#### 11.2 Environment Variables in Production

{% hint style="warning" %}
**Never commit** your `.env.local` file. Always set environment variables in your deployment platform's dashboard.
{% endhint %}

### 12. Troubleshooting

#### Common Issues

**Issue:** "Missing JamAI credentials" error

**Solution:**

* Verify `.env.local` exists in project root
* Check that variables are named correctly:
  * `JAMAI_API_KEY` (not `JAMAI_TOKEN`)
  * `JAMAI_PROJECT_ID` (not `PROJECT_ID`)
* Restart development server after adding env variables

***

**Issue:** Upload fails with "File too large"

**Solution:**

* Ensure image is under 10MB
* Compress image before uploading
* Or increase `MAX_FILE_SIZE` in src/app/api/jamai/upload/route.ts

***

**Issue:** Processing takes too long

**Solution:**

* This is normal for first request (table creation)
* Subsequent requests are faster
* Consider using concurrent: true for background processing

***

**Issue:** Extraction accuracy is poor

**Solution:**

* Use higher quality images (clear, well-lit receipts)
* Try different vision models (e.g., openai/gpt-4o for better accuracy)
* Refine prompts to be more specific
* Ensure prompts include proper table context and variable references like `${Image}`
* Adjust temperature slightly (lower for consistency, higher for variety)

***

**Issue:** History not refreshing

**Solution:**

* Check browser console for errors
* Verify `refreshTrigger` is incrementing
* Clear browser cache
* Check API route returns valid data

### 13. Best Practices

#### Security

1. **Protect API Keys**
   * Never commit `.env.local`
   * Use environment variables in production
   * Rotate API keys periodically
2. **Validate Input**
   * Always validate file types and sizes
   * Sanitize user inputs
   * Implement rate limiting for production
3. **Error Handling**
   * Never expose sensitive error details to users
   * Log errors server-side
   * Provide user-friendly error messages

#### Performance

1. **Optimize Images**
   * Compress images before upload
   * Consider image optimization libraries
2. **Caching**
   * SWR handles client-side caching
   * Consider server-side caching for frequently accessed data
3. **Concurrent Processing**
   * For bulk uploads, use `concurrent: true` in `addRow()`
   * Implement queue system for large batches

#### Code Quality

1. **TypeScript**
   * Define interfaces for all data structures
   * Avoid `any` types
   * Use strict mode
2. **Components**
   * Keep components focused and reusable
   * Extract common logic into hooks
   * Use proper TypeScript types for props
3. **Testing**
   * Add unit tests for utility functions
   * Test API routes
   * Implement E2E tests for critical flows

### 14. Next Steps

#### Enhancements to Try

1. **Multi-file Upload**
   * Process multiple receipts at once
   * Show progress for batch processing
2. **Export Functionality**
   * Export history to CSV
   * Generate expense reports
   * Integration with accounting software
3. **Advanced Features**
   * Receipt search and filtering
   * Date range selection
   * Analytics dashboard
   * Monthly/yearly summaries
4. **Mobile App**
   * Build React Native app
   * Use device camera for capture
   * Offline support with sync
5. **Authentication**
   * Add user accounts
   * Personal receipt history
   * Multi-user support

### 15. Resources

* [JamAI Base Documentation](https://docs.jamaibase.com)
* [Next.js Documentation](https://nextjs.org/docs)
* [TypeScript Documentation](https://www.typescriptlang.org/docs)
* [Tailwind CSS Documentation](https://tailwindcss.com/docs)
* [SWR Documentation](https://swr.vercel.app)

### 16. Support

If you encounter issues or have questions:

1. Check the [JamAI Base Documentation](https://docs.jamaibase.com)
2. Review this tutorial's troubleshooting section
3. Contact JamAI Base support

### Conclusion

You've successfully built a full-stack receipt extraction application using:

* **Next.js 16** with App Router
* **JamAI Base** for AI-powered data extraction
* **TypeScript** for type safety
* **Tailwind CSS** for modern UI
* **SWR** for efficient data fetching

The application demonstrates how easy it is to integrate AI capabilities into web applications using JamAI Base Action Tables. You can now extend this foundation to build more complex document processing applications.

Happy building!


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.jamaibase.com/developer-reference/typescript-sdk-documentation/examples/receipt-extractor-with-next-js.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
