file uploads quickstart - nself-org/cli GitHub Wiki
Get file uploads working in your nself app in 10 minutes.
Note: As of v0.9.6, storage commands have been consolidated under
nself service storage. Throughout this guide,nself storagerefers tonself service storagein the new command structure.
A complete file upload system with:
- Drag & drop interface
- Image thumbnails
- File management dashboard
- User storage quotas
- Secure permissions
- nself project initialized (
nself init) - MinIO enabled in your
.env.dev - Frontend app (Next.js recommended)
Edit .env.dev:
# Enable MinIO
MINIO_ENABLED=true
MINIO_BUCKET=uploads
STORAGE_PUBLIC_URL=http://storage.localhost
# Enable upload features
UPLOAD_ENABLE_THUMBNAILS=true
UPLOAD_ENABLE_COMPRESSION=true
UPLOAD_ENABLE_VIRUS_SCAN=false # Enable if you have ClamAV
# Thumbnail configuration
UPLOAD_THUMBNAIL_SIZES=150x150,300x300,600x600
UPLOAD_IMAGE_FORMATS=avif,webp,jpgnself build && nself startWait for services to start, then verify MinIO is running:
nself status | grep minio
# Should show: minio โ Runningnself service storage initExpected output:
โ Initializing storage system...
โ MinIO client installed
โ Bucket 'uploads' created
โ Storage system initialized
Upload Pipeline Status
======================
Backend: minio
Endpoint: http://minio:9000
Bucket: uploads
Features:
Multipart Upload: true
Thumbnails: true
Virus Scan: false
Compression: true
# Test with any image file
nself service storage upload ~/Downloads/photo.jpg --thumbnailsExpected output:
โ Upload pipeline initialized
Uploading: photo.jpg (2.3 MiB)
MIME type: image/jpeg
Destination: 2026/01/30/abc12345/photo.jpg
โ Generating thumbnails...
โ Upload complete!
File Details:
URL: http://storage.localhost/uploads/2026/01/30/abc12345/photo.jpg
Path: 2026/01/30/abc12345/photo.jpg
Size: 2.3 MiB
Type: image/jpeg
Open the URL in your browser to verify the upload worked.
nself service storage graphql-setupThis creates:
.backend/storage/
โโโ migrations/20260130_create_files_table.sql
โโโ metadata/tables/public_files.yaml
โโโ graphql/files.graphql
โโโ types/files.ts
โโโ hooks/useFiles.ts
# Get your database URL
source .env.dev
echo $DATABASE_URL
# Run migration
psql $DATABASE_URL < .backend/storage/migrations/*_create_files_table.sqlExpected output:
CREATE TABLE
CREATE INDEX
CREATE INDEX
CREATE FUNCTION
CREATE TRIGGER
CREATE POLICY
GRANT
# Make sure Hasura is running
nself status | grep hasura
# Apply metadata
hasura metadata apply# Copy to your Next.js frontend
cp .backend/storage/types/files.ts src/types/
cp .backend/storage/hooks/useFiles.ts src/hooks/Create src/components/FileUpload.tsx:
'use client';
import { useFileUpload, useUserFiles } from '@/hooks/useFiles';
import { useState } from 'react';
export default function FileUpload({ userId }: { userId: string }) {
const { upload, loading } = useFileUpload();
const { files, total, totalSize, refetch } = useUserFiles(userId);
const [error, setError] = useState<string | null>(null);
const handleUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
setError('File too large. Max size: 10MB');
return;
}
try {
setError(null);
const result = await upload(file, {
path: `users/${userId}/`,
isPublic: false,
});
console.log('Uploaded:', result.data.uploadFile);
refetch(); // Refresh file list
} catch (err) {
setError(err instanceof Error ? err.message : 'Upload failed');
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
};
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-8">
<h2 className="text-2xl font-bold mb-4">File Upload</h2>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center">
<input
type="file"
onChange={handleUpload}
disabled={loading}
className="block w-full text-sm text-gray-500
file:mr-4 file:py-2 file:px-4
file:rounded-full file:border-0
file:text-sm file:font-semibold
file:bg-blue-50 file:text-blue-700
hover:file:bg-blue-100
disabled:opacity-50 disabled:cursor-not-allowed"
/>
{loading && (
<p className="mt-2 text-sm text-gray-600">Uploading...</p>
)}
{error && (
<p className="mt-2 text-sm text-red-600">{error}</p>
)}
</div>
</div>
<div>
<h3 className="text-xl font-semibold mb-4">
Your Files ({total} files, {formatBytes(totalSize)})
</h3>
{files.length === 0 ? (
<p className="text-gray-500">No files uploaded yet.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{files.map((file) => (
<div key={file.id} className="border rounded-lg p-4">
{file.thumbnailUrl && (
<img
src={file.thumbnailUrl}
alt={file.name}
className="w-full h-48 object-cover rounded mb-2"
/>
)}
<p className="font-medium truncate">{file.name}</p>
<p className="text-sm text-gray-500">
{formatBytes(file.size)}
</p>
<a
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline text-sm"
>
Download
</a>
</div>
))}
</div>
)}
</div>
</div>
);
}Create src/app/files/page.tsx:
import FileUpload from '@/components/FileUpload';
import { auth } from '@/lib/auth'; // Your auth provider
export default async function FilesPage() {
const session = await auth();
if (!session?.user?.id) {
return <p>Please log in to upload files.</p>;
}
return <FileUpload userId={session.user.id} />;
}cd frontend
npm run devhttp://localhost:3000/files
- Click "Choose File"
- Select an image
- Wait for upload to complete
- File appears in the grid below
psql $DATABASE_URL -c "SELECT id, name, size, url FROM files;"Should show your uploaded file.
Congratulations! You now have:
- Storage Service - MinIO running with secure bucket
- Upload Pipeline - Automatic thumbnails and compression
- Database Schema - Files table with RLS permissions
- GraphQL API - Mutations and queries for file operations
- React UI - Upload component with file management
Install react-dropzone:
npm install react-dropzoneUpdate component:
import { useDropzone } from 'react-dropzone';
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop: async (acceptedFiles) => {
for (const file of acceptedFiles) {
await upload(file, {
path: `users/${userId}/`,
isPublic: false,
});
}
refetch();
},
maxSize: 10 * 1024 * 1024, // 10MB
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp'],
},
});const [progress, setProgress] = useState(0);
const handleUpload = async (file: File) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (event) => {
if (event.lengthComputable) {
const percentComplete = (event.loaded / event.total) * 100;
setProgress(percentComplete);
}
});
// Upload with XHR for progress tracking
};import { useFileDelete } from '@/hooks/useFiles';
const { remove } = useFileDelete();
const handleDelete = async (fileId: string) => {
if (confirm('Delete this file?')) {
await remove(fileId);
refetch();
}
};# Install ClamAV
brew install clamav # macOS
sudo apt-get install clamav # Ubuntu
# Update virus definitions
sudo freshclam
# Enable in .env.dev
UPLOAD_ENABLE_VIRUS_SCAN=true
# Restart
nself restartSee Storage Quotas Guide for implementing per-user storage limits.
See File Upload Security for production best practices.
Solution:
# Check MinIO status
nself status | grep minio
# Restart if needed
nself restart minioSolution:
# Check Hasura permissions
hasura console
# Go to Data โ files โ Permissions
# Verify user role has SELECT permissionSolution:
# Install ImageMagick
brew install imagemagick # macOS
sudo apt-get install imagemagick # Ubuntu
# Verify
convert --versionSolution:
# Check database connection
psql $DATABASE_URL -c "SELECT 1"
# If fails, verify .env.dev has correct DATABASE_URLSee the nself-chat repository for a complete working example with:
- File uploads
- Drag & drop
- Image previews
- Storage quotas
- Admin dashboard
Need help?