add file storage, local and prod

This commit is contained in:
Leon van Zyl
2025-11-30 08:16:24 +02:00
parent db09a4fff8
commit 654a3cf84c
19 changed files with 415 additions and 9 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "create-agentic-app",
"version": "1.1.17",
"version": "1.1.18",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "create-agentic-app",
"version": "1.1.17",
"version": "1.1.18",
"license": "MIT",
"dependencies": {
"chalk": "^5.3.0",

View File

@@ -1,6 +1,6 @@
{
"name": "create-agentic-app",
"version": "1.1.17",
"version": "1.1.18",
"description": "Scaffold a new agentic AI application with Next.js, Better Auth, and AI SDK",
"type": "module",
"bin": {

View File

@@ -68,6 +68,7 @@ src/
├── auth-client.ts # Better Auth client hooks
├── db.ts # Database connection
├── schema.ts # Drizzle schema (users, sessions, etc.)
├── storage.ts # File storage abstraction (Vercel Blob / local)
└── utils.ts # Utility functions (cn, etc.)
```
@@ -92,6 +93,9 @@ OPENROUTER_MODEL=openai/gpt-5-mini # or any model from openrouter.ai/models
# App
NEXT_PUBLIC_APP_URL=http://localhost:3000
# File Storage (optional)
BLOB_READ_WRITE_TOKEN= # Leave empty for local dev, set for Vercel Blob in production
```
## Available Scripts
@@ -162,14 +166,22 @@ The project includes technical documentation in `docs/`:
- Always run migrations after schema changes
- PostgreSQL is the database (not SQLite, MySQL, etc.)
7. **Component Creation**
7. **File Storage**
- Use the storage abstraction from `@/lib/storage`
- Automatically uses local storage (dev) or Vercel Blob (production)
- Import: `import { upload, deleteFile } from "@/lib/storage"`
- Example: `const result = await upload(buffer, "avatar.png", "avatars")`
- Storage switches based on `BLOB_READ_WRITE_TOKEN` environment variable
8. **Component Creation**
- Use existing shadcn/ui components when possible
- Follow the established patterns in `src/components/ui/`
- Support both light and dark modes
- Use TypeScript with proper types
8. **API Routes**
9. **API Routes**
- Follow Next.js 15 App Router conventions
- Use Route Handlers (route.ts files)
- Return Response objects
@@ -217,6 +229,14 @@ The project includes technical documentation in `docs/`:
3. Reference streaming docs: `docs/technical/ai/streaming.md`
4. Remember to use OpenRouter, not direct OpenAI
**Working with file storage:**
1. Import storage functions: `import { upload, deleteFile } from "@/lib/storage"`
2. Upload files: `const result = await upload(fileBuffer, "filename.png", "folder")`
3. Delete files: `await deleteFile(result.url)`
4. Storage automatically uses local filesystem in dev, Vercel Blob in production
5. Local files are saved to `public/uploads/` and served at `/uploads/`
## Package Manager
This project uses **pnpm** (see `pnpm-lock.yaml`). When running commands:

View File

@@ -7,6 +7,7 @@ A complete agentic coding boilerplate with authentication, PostgreSQL database,
- **🔐 Authentication**: Better Auth with Google OAuth integration
- **🗃️ Database**: Drizzle ORM with PostgreSQL
- **🤖 AI Integration**: Vercel AI SDK with OpenRouter (access to 100+ AI models)
- **📁 File Storage**: Automatic local/Vercel Blob storage with seamless switching
- **🎨 UI Components**: shadcn/ui with Tailwind CSS
- **⚡ Modern Stack**: Next.js 15, React 19, TypeScript
- **📱 Responsive**: Mobile-first design approach
@@ -113,6 +114,11 @@ OPENROUTER_MODEL="openai/gpt-5-mini"
# App URL (for production deployments)
NEXT_PUBLIC_APP_URL="http://localhost:3000"
# File Storage (Optional - for file upload functionality)
# Leave empty to use local storage (public/uploads/) in development
# Set to enable Vercel Blob storage in production
BLOB_READ_WRITE_TOKEN=""
```
**4. Database Setup**
@@ -163,6 +169,25 @@ Your application will be available at [http://localhost:3000](http://localhost:3
5. Copy the API key and add it to your `.env` file as `OPENROUTER_API_KEY`
6. Browse available models at <a href="https://openrouter.ai/models" target="_blank">OpenRouter Models</a>
### File Storage Configuration
The project includes a flexible storage abstraction that automatically switches between local filesystem storage (development) and Vercel Blob storage (production).
**For Development (Local Storage):**
- Leave `BLOB_READ_WRITE_TOKEN` empty or unset in your `.env` file
- Files are automatically stored in `public/uploads/`
- Files are served at `/uploads/` URL path
- No external service or configuration needed
**For Production (Vercel Blob):**
1. Go to <a href="https://vercel.com/dashboard" target="_blank">Vercel Dashboard</a>
2. Navigate to your project → **Storage** tab
3. Click **Create****Blob**
4. Copy the `BLOB_READ_WRITE_TOKEN` from the integration
5. Add it to your production environment variables
The storage service automatically detects which backend to use based on the presence of the `BLOB_READ_WRITE_TOKEN` environment variable.
## 🗂️ Project Structure
```
@@ -182,6 +207,7 @@ src/
├── auth-client.ts # Client-side auth utilities
├── db.ts # Database connection
├── schema.ts # Database schema
├── storage.ts # File storage abstraction
└── utils.ts # General utilities
```
@@ -236,6 +262,7 @@ Ensure these are set in your production environment:
- `OPENROUTER_API_KEY` - OpenRouter API key (optional, for AI chat functionality)
- `OPENROUTER_MODEL` - Model name from OpenRouter (optional, defaults to openai/gpt-5-mini)
- `NEXT_PUBLIC_APP_URL` - Your production domain
- `BLOB_READ_WRITE_TOKEN` - Vercel Blob token (optional, uses local storage if not set)
## 🎥 Tutorial Video

View File

@@ -41,3 +41,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# uploads
public/uploads

View File

@@ -22,6 +22,7 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.3",
"@vercel/blob": "^2.0.0",
"ai": "^5.0.86",
"better-auth": "^1.3.34",
"class-variance-authority": "^0.7.1",

View File

@@ -24,6 +24,10 @@ interface DiagnosticsResponse {
ai: {
configured: boolean;
};
storage: {
configured: boolean;
type: "local" | "remote";
};
overallStatus: StatusLevel;
}
@@ -115,6 +119,10 @@ export async function GET(req: Request) {
env.BETTER_AUTH_SECRET && env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET;
const aiConfigured = env.OPENROUTER_API_KEY; // We avoid live-calling the AI provider here
// Storage configuration check
const storageConfigured = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
const storageType: "local" | "remote" = storageConfigured ? "remote" : "local";
const overallStatus: StatusLevel = (() => {
if (!env.POSTGRES_URL || !dbConnected || !schemaApplied) return "error";
if (!authConfigured) return "error";
@@ -138,6 +146,10 @@ export async function GET(req: Request) {
ai: {
configured: aiConfigured,
},
storage: {
configured: storageConfigured,
type: storageType,
},
overallStatus,
};

View File

@@ -43,7 +43,7 @@ export default function Home() {
<div className="relative pb-[56.25%] h-0 overflow-hidden rounded-lg border">
<iframe
className="absolute top-0 left-0 w-full h-full"
src="https://www.youtube.com/embed/T0zFZsr_d0Q"
src="https://www.youtube.com/embed/JQ86N3WOAh4"
title="Agentic Coding Boilerplate Tutorial"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen

View File

@@ -26,6 +26,10 @@ type DiagnosticsResponse = {
ai: {
configured: boolean;
};
storage: {
configured: boolean;
type: "local" | "remote";
};
overallStatus: "ok" | "warn" | "error";
};
@@ -102,6 +106,16 @@ export function SetupChecklist() {
? "Set OPENROUTER_API_KEY for AI chat"
: undefined,
},
{
key: "storage",
label: "File storage (optional)",
ok: true, // Always considered "ok" since local storage works
detail: data?.storage
? data.storage.type === "remote"
? "Using Vercel Blob storage"
: "Using local storage (public/uploads/)"
: undefined,
},
] as const;
const completed = steps.filter((s) => s.ok).length;

View File

@@ -0,0 +1,102 @@
import { put, del } from "@vercel/blob";
import { writeFile, mkdir } from "fs/promises";
import { join } from "path";
import { existsSync } from "fs";
/**
* Result from uploading a file to storage
*/
export interface StorageResult {
url: string; // Public URL to access the file
pathname: string; // Path/key of the stored file
}
/**
* Uploads a file to storage (Vercel Blob or local filesystem)
*
* @param buffer - File contents as a Buffer
* @param filename - Name of the file (e.g., "image.png")
* @param folder - Optional folder/prefix (e.g., "avatars")
* @returns StorageResult with url and pathname
*
* @example
* ```ts
* const result = await upload(fileBuffer, "avatar.png", "avatars");
* console.log(result.url); // https://blob.vercel.io/... or /uploads/avatars/avatar.png
* ```
*/
export async function upload(
buffer: Buffer,
filename: string,
folder?: string
): Promise<StorageResult> {
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
if (hasVercelBlob) {
// Use Vercel Blob storage
const pathname = folder ? `${folder}/${filename}` : filename;
const blob = await put(pathname, buffer, {
access: "public",
});
return {
url: blob.url,
pathname: blob.pathname,
};
} else {
// Use local filesystem storage
const uploadsDir = join(process.cwd(), "public", "uploads");
const targetDir = folder ? join(uploadsDir, folder) : uploadsDir;
// Ensure the directory exists
if (!existsSync(targetDir)) {
await mkdir(targetDir, { recursive: true });
}
// Write the file
const filepath = join(targetDir, filename);
await writeFile(filepath, buffer);
// Return local URL
const pathname = folder ? `${folder}/${filename}` : filename;
const url = `/uploads/${pathname}`;
return {
url,
pathname,
};
}
}
/**
* Deletes a file from storage
*
* @param url - The URL of the file to delete
*
* @example
* ```ts
* await deleteFile("https://blob.vercel.io/...");
* // or
* await deleteFile("/uploads/avatars/avatar.png");
* ```
*/
export async function deleteFile(url: string): Promise<void> {
const hasVercelBlob = Boolean(process.env.BLOB_READ_WRITE_TOKEN);
if (hasVercelBlob) {
// Delete from Vercel Blob
await del(url);
} else {
// Delete from local filesystem
// Extract pathname from URL (e.g., /uploads/avatars/avatar.png -> avatars/avatar.png)
const pathname = url.replace(/^\/uploads\//, "");
const filepath = join(process.cwd(), "public", "uploads", pathname);
// Only attempt to delete if file exists
if (existsSync(filepath)) {
const { unlink } = await import("fs/promises");
await unlink(filepath);
}
}
}