Skip to content

React & Next.js

Comprehensive guide for integrating Splinterpic into React and Next.js applications with TypeScript support, hooks, and best practices.

Terminal window
npm install axios swr
# or
yarn add axios swr
# or
pnpm add axios swr

Create a types file for Splinterpic:

types/splinterpic.ts
export interface GenerateImageRequest {
prompt: string;
model?: string;
template_id?: string;
collection_id?: string;
}
export interface Image {
id: string;
prompt: string;
model: string;
r2_key: string;
r2_url: string;
created_at: string;
user_id: string;
cost: number;
template_id?: string;
collection_id?: string;
is_deleted: boolean;
}
export interface ImagesResponse {
images: Image[];
total: number;
}
export interface Model {
id: string;
name: string;
description: string;
cost_per_image: number;
is_recommended: boolean;
}
export interface ModelsResponse {
models: Model[];
}
export interface Collection {
id: string;
name: string;
description?: string;
created_at: string;
user_id: string;
}
export interface CollectionsResponse {
collections: Collection[];
}
export interface ApiError {
error: string;
}

Create a centralized API client:

lib/splinterpic.ts
import axios, { AxiosInstance } from 'axios';
import type {
GenerateImageRequest,
Image,
ImagesResponse,
Model,
ModelsResponse,
Collection,
CollectionsResponse,
} from '@/types/splinterpic';
export class SplinterpicClient {
private client: AxiosInstance;
constructor(baseURL: string) {
this.client = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
timeout: 30000,
});
}
async generate(request: GenerateImageRequest): Promise<Image> {
const { data } = await this.client.post<Image>('/api/generate', request);
return data;
}
async getImages(filters?: {
limit?: number;
offset?: number;
collection_id?: string;
}): Promise<ImagesResponse> {
const { data } = await this.client.get<ImagesResponse>('/api/images', {
params: filters,
});
return data;
}
async getImage(imageId: string): Promise<Image> {
const { data } = await this.client.get<Image>(`/api/images/${imageId}`);
return data;
}
async deleteImage(imageId: string): Promise<void> {
await this.client.delete(`/api/images/${imageId}`);
}
async getModels(): Promise<ModelsResponse> {
const { data } = await this.client.get<ModelsResponse>('/api/models');
return data;
}
async createCollection(name: string, description?: string): Promise<Collection> {
const { data } = await this.client.post<Collection>('/api/collections', {
name,
description,
});
return data;
}
async getCollections(): Promise<CollectionsResponse> {
const { data } = await this.client.get<CollectionsResponse>('/api/collections');
return data;
}
}
// Export singleton instance
export const splinterpic = new SplinterpicClient(
process.env.NEXT_PUBLIC_SPLINTERPIC_API_URL || ''
);
hooks/useImageGeneration.ts
import { useState } from 'react';
import { splinterpic } from '@/lib/splinterpic';
import type { GenerateImageRequest, Image } from '@/types/splinterpic';
export function useImageGeneration() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [image, setImage] = useState<Image | null>(null);
const generate = async (request: GenerateImageRequest) => {
setLoading(true);
setError(null);
setImage(null);
try {
const result = await splinterpic.generate(request);
setImage(result);
return result;
} catch (err: any) {
const errorMessage = err.response?.data?.error || err.message || 'Failed to generate image';
setError(errorMessage);
throw new Error(errorMessage);
} finally {
setLoading(false);
}
};
const reset = () => {
setImage(null);
setError(null);
setLoading(false);
};
return {
generate,
loading,
error,
image,
reset,
};
}
hooks/useImages.ts
import useSWR from 'swr';
import { splinterpic } from '@/lib/splinterpic';
import type { ImagesResponse } from '@/types/splinterpic';
interface UseImagesOptions {
limit?: number;
offset?: number;
collection_id?: string;
}
export function useImages(options: UseImagesOptions = {}) {
const key = ['images', options];
const { data, error, isLoading, mutate } = useSWR<ImagesResponse>(
key,
() => splinterpic.getImages(options),
{
revalidateOnFocus: false,
dedupingInterval: 5000,
}
);
return {
images: data?.images || [],
total: data?.total || 0,
isLoading,
error,
refresh: mutate,
};
}
hooks/useModels.ts
import useSWR from 'swr';
import { splinterpic } from '@/lib/splinterpic';
import type { Model } from '@/types/splinterpic';
export function useModels() {
const { data, error, isLoading } = useSWR<Model[]>(
'models',
async () => {
const response = await splinterpic.getModels();
return response.models;
},
{
revalidateOnMount: true,
revalidateOnFocus: false,
dedupingInterval: 60000, // Cache for 1 minute
}
);
return {
models: data || [],
isLoading,
error,
recommendedModel: data?.find(m => m.is_recommended),
};
}
components/ImageGenerationForm.tsx
'use client';
import { useState } from 'react';
import { useImageGeneration } from '@/hooks/useImageGeneration';
import { useModels } from '@/hooks/useModels';
export function ImageGenerationForm() {
const [prompt, setPrompt] = useState('');
const [selectedModel, setSelectedModel] = useState('');
const { generate, loading, error, image } = useImageGeneration();
const { models, recommendedModel } = useModels();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!prompt.trim()) {
return;
}
try {
await generate({
prompt: prompt.trim(),
model: selectedModel || recommendedModel?.id,
});
} catch (err) {
// Error is already handled in the hook
console.error('Generation failed:', err);
}
};
return (
<div className="max-w-2xl mx-auto p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="prompt" className="block text-sm font-medium mb-2">
Image Description
</label>
<textarea
id="prompt"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="A serene mountain landscape at sunset..."
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
rows={4}
disabled={loading}
/>
</div>
<div>
<label htmlFor="model" className="block text-sm font-medium mb-2">
AI Model
</label>
<select
id="model"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
className="w-full p-3 border rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={loading}
>
<option value="">Use recommended model</option>
{models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} - ${model.cost_per_image.toFixed(4)}
{model.is_recommended && ' (Recommended)'}
</option>
))}
</select>
</div>
<button
type="submit"
disabled={loading || !prompt.trim()}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{loading ? 'Generating...' : 'Generate Image'}
</button>
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{image && (
<div className="mt-6 space-y-4">
<div className="relative aspect-square rounded-lg overflow-hidden">
<img
src={image.r2_url}
alt={image.prompt}
className="w-full h-full object-cover"
/>
</div>
<div className="text-sm text-gray-600 space-y-1">
<p>
<strong>ID:</strong> {image.id}
</p>
<p>
<strong>Model:</strong> {image.model}
</p>
<p>
<strong>Cost:</strong> ${image.cost.toFixed(4)}
</p>
</div>
</div>
)}
</form>
</div>
);
}
components/ImageGallery.tsx
'use client';
import { useState } from 'react';
import { useImages } from '@/hooks/useImages';
import type { Image } from '@/types/splinterpic';
export function ImageGallery() {
const [page, setPage] = useState(0);
const limit = 12;
const { images, total, isLoading, error, refresh } = useImages({
limit,
offset: page * limit,
});
const totalPages = Math.ceil(total / limit);
if (error) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
Failed to load images. Please try again.
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Your Images</h2>
<button
onClick={() => refresh()}
className="px-4 py-2 text-sm font-medium text-blue-600 hover:text-blue-700"
>
Refresh
</button>
</div>
{isLoading ? (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{Array.from({ length: limit }).map((_, i) => (
<div
key={i}
className="aspect-square bg-gray-200 rounded-lg animate-pulse"
/>
))}
</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((image) => (
<ImageCard key={image.id} image={image} onDelete={refresh} />
))}
</div>
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(0, p - 1))}
disabled={page === 0}
className="px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-gray-600">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="px-4 py-2 border rounded-lg hover:bg-gray-50 disabled:opacity-50"
>
Next
</button>
</div>
)}
</>
)}
</div>
);
}
function ImageCard({ image, onDelete }: { image: Image; onDelete: () => void }) {
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this image?')) {
return;
}
setDeleting(true);
try {
await splinterpic.deleteImage(image.id);
onDelete();
} catch (err) {
alert('Failed to delete image');
setDeleting(false);
}
};
return (
<div className="group relative aspect-square rounded-lg overflow-hidden bg-gray-100">
<img
src={image.r2_url}
alt={image.prompt}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-60 transition-all flex items-end p-4">
<div className="opacity-0 group-hover:opacity-100 transition-opacity text-white text-sm space-y-2 w-full">
<p className="font-medium truncate">{image.prompt}</p>
<div className="flex items-center justify-between">
<span className="text-xs">${image.cost.toFixed(4)}</span>
<button
onClick={handleDelete}
disabled={deleting}
className="px-3 py-1 bg-red-600 hover:bg-red-700 rounded text-xs font-medium disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
</div>
);
}
app/page.tsx
import { ImageGenerationForm } from '@/components/ImageGenerationForm';
import { ImageGallery } from '@/components/ImageGallery';
export default function HomePage() {
return (
<main className="container mx-auto py-12 space-y-12">
<section>
<h1 className="text-4xl font-bold text-center mb-8">
AI Image Generation
</h1>
<ImageGenerationForm />
</section>
<section>
<ImageGallery />
</section>
</main>
);
}
app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const response = await fetch(
`${process.env.SPLINTERPIC_API_URL}/api/generate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.error || 'Generation failed' },
{ status: response.status }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Generation error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Terminal window
# Public (client-side)
NEXT_PUBLIC_SPLINTERPIC_API_URL=https://splinterpic-worker.your-name.workers.dev
# Private (server-side only)
SPLINTERPIC_API_URL=https://splinterpic-worker.your-name.workers.dev
components/ImageGenerationWithProgress.tsx
'use client';
import { useState } from 'react';
import { useImageGeneration } from '@/hooks/useImageGeneration';
export function ImageGenerationWithProgress() {
const [prompt, setPrompt] = useState('');
const [progress, setProgress] = useState(0);
const { generate, loading, error, image } = useImageGeneration();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Simulate progress
const progressInterval = setInterval(() => {
setProgress(prev => {
if (prev >= 90) return prev;
return prev + 10;
});
}, 500);
try {
await generate({ prompt });
setProgress(100);
} catch (err) {
console.error(err);
} finally {
clearInterval(progressInterval);
setTimeout(() => setProgress(0), 1000);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe your image..."
className="w-full p-3 border rounded-lg"
rows={4}
/>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-3 rounded-lg"
>
Generate
</button>
{loading && (
<div className="space-y-2">
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<p className="text-sm text-gray-600 text-center">
Generating... {progress}%
</p>
</div>
)}
{error && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg text-red-700">
{error}
</div>
)}
{image && (
<img
src={image.r2_url}
alt={image.prompt}
className="w-full rounded-lg"
/>
)}
</form>
);
}
components/BatchGeneration.tsx
'use client';
import { useState } from 'react';
import { splinterpic } from '@/lib/splinterpic';
import type { Image } from '@/types/splinterpic';
interface BatchResult {
prompt: string;
image?: Image;
error?: string;
status: 'pending' | 'generating' | 'completed' | 'failed';
}
export function BatchGeneration() {
const [prompts, setPrompts] = useState('');
const [results, setResults] = useState<BatchResult[]>([]);
const [generating, setGenerating] = useState(false);
const handleBatchGenerate = async () => {
const promptList = prompts
.split('\n')
.map(p => p.trim())
.filter(Boolean);
if (promptList.length === 0) return;
setGenerating(true);
setResults(promptList.map(prompt => ({ prompt, status: 'pending' })));
for (let i = 0; i < promptList.length; i++) {
const prompt = promptList[i];
setResults(prev => {
const updated = [...prev];
updated[i] = { ...updated[i], status: 'generating' };
return updated;
});
try {
const image = await splinterpic.generate({ prompt });
setResults(prev => {
const updated = [...prev];
updated[i] = { prompt, image, status: 'completed' };
return updated;
});
// Rate limiting - wait 2 seconds between requests
if (i < promptList.length - 1) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
} catch (err: any) {
setResults(prev => {
const updated = [...prev];
updated[i] = {
prompt,
error: err.message || 'Failed',
status: 'failed',
};
return updated;
});
}
}
setGenerating(false);
};
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">
Enter prompts (one per line)
</label>
<textarea
value={prompts}
onChange={(e) => setPrompts(e.target.value)}
placeholder="Mountain landscape&#10;Ocean sunset&#10;City at night"
className="w-full p-3 border rounded-lg font-mono text-sm"
rows={8}
disabled={generating}
/>
</div>
<button
onClick={handleBatchGenerate}
disabled={generating || !prompts.trim()}
className="w-full bg-blue-600 text-white py-3 rounded-lg font-semibold hover:bg-blue-700 disabled:bg-gray-400"
>
{generating ? 'Generating...' : 'Generate All'}
</button>
{results.length > 0 && (
<div className="space-y-4">
<h3 className="font-semibold">Results ({results.length})</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{results.map((result, index) => (
<div
key={index}
className="border rounded-lg p-4 space-y-2"
>
<p className="text-sm font-medium truncate">{result.prompt}</p>
{result.status === 'pending' && (
<p className="text-sm text-gray-500">Waiting...</p>
)}
{result.status === 'generating' && (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />
<p className="text-sm text-blue-600">Generating...</p>
</div>
)}
{result.status === 'completed' && result.image && (
<img
src={result.image.r2_url}
alt={result.prompt}
className="w-full aspect-square object-cover rounded"
/>
)}
{result.status === 'failed' && (
<p className="text-sm text-red-600">{result.error}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}

Always use environment variables for API URLs:

// Good
const apiUrl = process.env.NEXT_PUBLIC_SPLINTERPIC_API_URL;
// Bad - Never hardcode
const apiUrl = 'https://splinterpic-worker.workers.dev';

Wrap components with error boundaries:

components/ErrorBoundary.tsx
'use client';
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback || <div>Something went wrong</div>;
}
return this.props.children;
}
}

Always show loading indicators:

{loading ? (
<div className="flex items-center justify-center p-8">
<div className="w-8 h-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
</div>
) : (
<Content />
)}

Use TypeScript for all API interactions:

// Good
const image: Image = await splinterpic.generate(request);
// Bad
const image = await splinterpic.generate(request);