| name | nextjs-modal-integration |
| description | Modal.com integration patterns for Next.js applications. PROACTIVELY activate for: (1) Next.js + Modal backend setup, (2) AI inference from Next.js (LLMs, image generation), (3) Video/audio processing backends, (4) Heavy compute offloading from Vercel, (5) GPU workloads for Next.js apps, (6) Webhook integration between Next.js and Modal, (7) File upload processing, (8) Background job processing, (9) Serverless AI API endpoints, (10) Next.js + Modal authentication patterns. Provides: Architecture patterns, API route integration, webhook handling, file upload workflows, CORS configuration, warm container patterns, streaming responses, and production-ready examples. |
Quick Reference
| Pattern | Use Case | Cold Start |
|---|
| API Route → Modal | Simple request/response | ~500ms |
| API Route → Modal (warm) | Production APIs | <100ms |
| Webhook + Spawn | Long-running jobs | N/A (async) |
| Streaming Response | LLM text generation | ~500ms first token |
When to Use This Skill
Use for Next.js + Modal integration:
- AI inference that's too heavy for Edge/Vercel Functions
- Video/audio processing with FFmpeg
- Background jobs exceeding Vercel's 60s timeout
- GPU workloads (image generation, LLMs, embeddings)
- Cost-effective scaling for burst compute
Architecture principle: Next.js handles UI/auth/routing, Modal handles heavy compute.
Next.js + Modal.com Integration (2025)
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ Next.js (Vercel) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ Pages/ │ │ API │ │ Server │ │
│ │ App │ │ Routes │ │ Actions │ │
│ │ Router │ │ │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────────┬──────────┘ │
└─────────┼────────────────┼───────────────────┼─────────────┘
│ │ │
└────────────────┼───────────────────┘
│ HTTPS
▼
┌─────────────────────────────────────────────────────────────┐
│ Modal.com │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ FastAPI │ │ GPU │ │ Background │ │
│ │ Endpoint │ │ Functions │ │ Jobs │ │
│ │ │ │ (A100) │ │ (.spawn()) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Modal Backend Setup
Basic FastAPI Endpoint
import modal
from datetime import datetime
app = modal.App("nextjs-backend")
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install("fastapi", "pydantic")
)
@app.function(image=image)
@modal.concurrent(max_inputs=100, target_inputs=50)
@modal.asgi_app()
def api():
"""FastAPI endpoint for Next.js frontend"""
from fastapi import FastAPI, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from pydantic import BaseModel, Field
web_app = FastAPI(title="Next.js Backend API")
web_app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"https://*.vercel.app",
"https://yourdomain.com",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
security = HTTPBearer()
API_KEY = "your-secret-key"
def verify_token():
creds.credentials != API_KEY:
HTTPException(status_code=, detail=)
creds.credentials
():
data: = Field(..., min_length=)
options: = {}
():
result:
processed_at:
():
result =
ProcessResponse(
result=result,
processed_at=datetime.utcnow().isoformat()
)
():
{: , : datetime.utcnow().isoformat()}
web_app
Deploy with:
modal deploy modal_backend/app.py
Next.js API Route Integration
Basic API Route (App Router)
import { NextRequest, NextResponse } from 'next/server';
const MODAL_API_URL = process.env.MODAL_API_URL!;
const MODAL_API_KEY = process.env.MODAL_API_KEY!;
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const response = await fetch(`${MODAL_API_URL}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${MODAL_API_KEY}`,
},
body: JSON.stringify({
data: body.data,
options: body.options || {},
}),
});
if (!response.ok) {
const error = await response.text();
throw ();
}
result = response.();
.(result);
} (error) {
.(, error);
.(
{ : },
{ : }
);
}
}
Environment Variables
# .env.local
MODAL_API_URL=https://your-workspace--nextjs-backend-api.modal.run
MODAL_API_KEY=your-secret-key
AI Image Generation Example
Modal Backend
import modal
app = modal.App("image-generator")
image = (
modal.Image.debian_slim(python_version="3.11")
.pip_install(
"fastapi",
"torch",
"diffusers",
"transformers",
"accelerate",
"pydantic",
)
)
models_volume = modal.Volume.from_name("sd-models", create_if_missing=True)
@app.cls(
image=image,
gpu="A100-40GB",
volumes={"/models": models_volume},
min_containers=1,
max_containers=5,
container_idle_timeout=300,
)
class ImageGenerator:
@modal.enter()
def setup(self):
import torch
from diffusers import StableDiffusionXLPipeline
print("Loading SDXL model...")
self.pipe = StableDiffusionXLPipeline.from_pretrained(
"stabilityai/stable-diffusion-xl-base-1.0",
torch_dtype=torch.float16,
cache_dir="/models",
)
self.pipe.to("cuda")
print("Model ready!")
@modal.method()
def generate(
self,
prompt: str,
negative_prompt: = ,
width: = ,
height: = ,
steps: = ,
) -> :
io
image = .pipe(
prompt=prompt,
negative_prompt=negative_prompt,
width=width,
height=height,
num_inference_steps=steps,
).images[]
buffer = io.BytesIO()
image.save(buffer, =)
buffer.getvalue()
():
fastapi FastAPI, HTTPException
fastapi.middleware.cors CORSMiddleware
fastapi.responses Response
pydantic BaseModel, Field
web_app = FastAPI()
web_app.add_middleware(
CORSMiddleware,
allow_origins=[, ],
allow_methods=[],
allow_headers=[],
)
():
prompt: = Field(..., min_length=, max_length=)
negative_prompt: =
width: = Field(, ge=, le=)
height: = Field(, ge=, le=)
steps: = Field(, ge=, le=)
():
generator = ImageGenerator()
:
image_bytes = generator.generate.remote(
prompt=req.prompt,
negative_prompt=req.negative_prompt,
width=req.width,
height=req.height,
steps=req.steps,
)
Response(content=image_bytes, media_type=)
Exception e:
HTTPException(status_code=, detail=(e))
web_app
Next.js API Route
import { NextRequest, NextResponse } from 'next/server';
const MODAL_API_URL = process.env.MODAL_IMAGE_GEN_URL!;
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const response = await fetch(`${MODAL_API_URL}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: body.prompt,
negative_prompt: body.negativePrompt || '',
width: body.width || 1024,
height: body.height || 1024,
steps: body.steps || 30,
}),
});
if (!response.ok) {
throw new ();
}
imageBuffer = response.();
(imageBuffer, {
: {
: ,
: ,
},
});
} (error) {
.(, error);
.({ : }, { : });
}
}
React Component
'use client';
import { useState } from 'react';
export default function ImageGenerator() {
const [prompt, setPrompt] = useState('');
const [imageUrl, setImageUrl] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const generateImage = async () => {
setLoading(true);
setError('');
try {
const response = await fetch('/api/generate-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
throw new Error('Generation failed');
}
const blob = await response.();
url = .(blob);
(url);
} (err) {
(err ? err. : );
} {
();
}
};
(
);
}
Long-Running Jobs with Webhooks
For jobs exceeding Vercel's 60-second timeout, use webhooks.
Modal Backend with Webhooks
import modal
import httpx
app = modal.App("job-processor")
image = modal.Image.debian_slim().pip_install("fastapi", "httpx", "pydantic")
@app.function(image=image, timeout=3600)
def process_long_job(job_id: str, data: dict, callback_url: str):
"""Long-running job that calls back when complete"""
import time
print(f"Processing job {job_id}...")
time.sleep(60)
result = {"job_id": job_id, "status": "completed", "output": "processed data"}
httpx.post(
callback_url,
json=result,
headers={"X-Webhook-Secret": "your-webhook-secret"},
timeout=30,
)
return result
@app.function(image=image)
@modal.asgi_app()
def api():
from fastapi import FastAPI, BackgroundTasks
from pydantic import BaseModel
import uuid
web_app = FastAPI()
class JobRequest():
data:
callback_url:
():
job_id = (uuid.uuid4())
process_long_job.spawn(job_id, req.data, req.callback_url)
{
: job_id,
: ,
:
}
web_app
Next.js Webhook Handler
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const signature = req.headers.get('X-Webhook-Secret');
if (signature !== WEBHOOK_SECRET) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
try {
const body = await req.json();
const { job_id, status, output } = body;
await prisma.job.update({
where: { id: job_id },
data: {
status,
output,
completedAt: new Date(),
},
});
.({ : });
} (error) {
.(, error);
.({ : }, { : });
}
}
Job Submission from Next.js
import { NextRequest, NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { auth } from '@/lib/auth';
const MODAL_API_URL = process.env.MODAL_JOBS_URL!;
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!;
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await req.json();
const job = await prisma.job.create({
data: {
userId: session.user.id,
: ,
: body,
},
});
response = (, {
: ,
: { : },
: .({
: body,
: ,
}),
});
(!response.) {
();
}
.({
: job.,
: ,
});
} (error) {
.(, error);
.({ : }, { : });
}
}
File Upload Processing
Modal Backend for File Processing
import modal
app = modal.App("file-processor")
image = modal.Image.debian_slim().pip_install(
"fastapi",
"python-multipart",
"pillow",
)
@app.function(image=image, timeout=300)
@modal.asgi_app()
def api():
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import Response
from PIL import Image
import io
web_app = FastAPI()
@web_app.post("/resize-image")
async def resize_image(
file: UploadFile = File(...),
width: int = 800,
height: int = 600,
):
if not file.content_type.startswith('image/'):
raise HTTPException(400, "File must be an image")
contents = await file.read()
img = Image.open(io.BytesIO(contents))
img = img.resize((width, height), Image.Resampling.LANCZOS)
buffer = io.BytesIO()
img.save(buffer, format="PNG")
return Response(
content=buffer.getvalue(),
media_type=,
)
web_app
Next.js File Upload Handler
import { NextRequest, NextResponse } from 'next/server';
const MODAL_API_URL = process.env.MODAL_FILES_URL!;
export async function POST(req: NextRequest) {
try {
const formData = await req.formData();
const file = formData.get('file') as File;
const width = formData.get('width') || '800';
const height = formData.get('height') || '600';
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
const modalFormData = new FormData();
modalFormData.append('file', file);
const response = await fetch(
,
{
: ,
: modalFormData,
}
);
(!response.) {
();
}
imageBuffer = response.();
(imageBuffer, {
: { : },
});
} (error) {
.(, error);
.({ : }, { : });
}
}
Streaming LLM Responses
Modal Backend with Streaming
import modal
app = modal.App("llm-api")
image = modal.Image.debian_slim().pip_install(
"fastapi",
"transformers",
"torch",
"accelerate",
"sse-starlette",
)
@app.cls(image=image, gpu="A100", min_containers=1)
class LLMServer:
@modal.enter()
def setup(self):
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
self.tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-chat-hf")
self.model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-2-7b-chat-hf",
torch_dtype=torch.float16,
).to("cuda")
@modal.method()
def generate_stream(self, prompt: str, max_tokens: int = 512):
"""Generator that yields tokens one at a time"""
from transformers import TextIteratorStreamer
from threading import Thread
inputs = self.tokenizer(prompt, return_tensors="pt").to("cuda")
streamer = TextIteratorStreamer(self.tokenizer, skip_special_tokens=True)
generation_kwargs = (
**inputs,
max_new_tokens=max_tokens,
streamer=streamer,
)
thread = Thread(target=.model.generate, kwargs=generation_kwargs)
thread.start()
token streamer:
token
thread.join()
():
fastapi FastAPI
sse_starlette.sse EventSourceResponse
pydantic BaseModel
web_app = FastAPI()
():
prompt:
max_tokens: =
():
llm = LLMServer()
():
token llm.generate_stream.remote_gen(req.prompt, req.max_tokens):
{: token}
EventSourceResponse(event_generator())
web_app
Next.js Streaming Handler
import { NextRequest } from 'next/server';
const MODAL_API_URL = process.env.MODAL_LLM_URL!;
export async function POST(req: NextRequest) {
const { prompt } = await req.json();
const response = await fetch(`${MODAL_API_URL}/generate-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
return new Response(response.body, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
}
React Component with Streaming
'use client';
import { useState } from 'react';
export default function Chat() {
const [prompt, setPrompt] = useState('');
const [response, setResponse] = useState('');
const [loading, setLoading] = useState(false);
const sendMessage = async () => {
setLoading(true);
setResponse('');
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = res.body?.getReader();
const decoder = new TextDecoder();
while (reader) {
const { done, value } = await reader.read();
if (done) break;
chunk = decoder.(value);
lines = chunk.();
( line lines) {
(line.()) {
token = line.();
( prev + token);
}
}
}
();
};
(
);
}
Best Practices
1. Keep Modal API Keys Server-Side
const MODAL_API_KEY = process.env.MODAL_API_KEY;
2. Use Warm Containers for Production
@app.cls(
min_containers=1,
max_containers=10,
container_idle_timeout=300,
)
class MyService:
pass
3. Handle Vercel Timeouts
4. Implement Request Timeouts
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 55000);
try {
const response = await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timeoutId);
}
5. Cache Modal Responses
import { unstable_cache } from 'next/cache';
const getCachedResult = unstable_cache(
async (id: string) => {
const response = await fetch(`${MODAL_API_URL}/process/${id}`);
return response.json();
},
['modal-result'],
{ revalidate: 3600 }
);
Common Pitfalls
1. CORS Errors
web_app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:3000",
"https://*.vercel.app",
"https://yourdomain.com",
],
allow_methods=["*"],
allow_headers=["*"],
)
2. Cold Start Latency
@app.function(min_containers=1)
def api():
pass
3. Large Payloads
4. Error Handling
try {
const response = await fetch(MODAL_API_URL);
if (!response.ok) {
console.error(`Modal error: ${response.status}`, await response.text());
return NextResponse.json(
{ error: 'Service temporarily unavailable' },
{ status: 503 }
);
}
} catch (error) {
console.error('Network error:', error);
return NextResponse.json(
{ error: 'Could not connect to service' },
{ status: 503 }
);
}
Deployment Checklist
-
Deploy Modal backend first
modal deploy modal_backend/app.py
-
Set environment variables in Vercel
MODAL_API_URL=https://your-workspace--app-api.modal.run
MODAL_API_KEY=your-secret-key
WEBHOOK_SECRET=your-webhook-secret
-
Update CORS for production domain
-
Enable warm containers for production
@app.function(min_containers=1)
-
Monitor costs
modal app stats your-app-name
Related Skills
nextjs-server-actions - Server Actions patterns
nextjs-caching - Caching strategies
nextjs-deployment - Deployment configurations