Vercel's built-in logs disappear after 1 hour on free/Pro plans. Set up persistent logging for Next.js API routes, server components, and middleware with a log drain or SDK.
Vercel's built-in Runtime Logs show console.log output from serverless functions, but they have hard limits:
For any production application, this isn't enough. When a user reports "checkout failed yesterday afternoon," logs from yesterday are already gone on Free/Pro plans.
This tutorial covers three approaches to get persistent, searchable logs from a Next.js application on Vercel.
Install a logging SDK and use it in API routes and server components:
npm install @getlogflow/js
Create a shared logger:
// lib/logger.ts
import LogFlow from '@getlogflow/js'
export const logger = new LogFlow({
apiKey: process.env.LOGFLOW_API_KEY!,
service: 'web',
})
Use it in API routes:
// app/api/orders/route.ts
import { logger } from '@/lib/logger'
import { NextRequest, NextResponse } from 'next/server'
export async function POST(req: NextRequest) {
const body = await req.json()
logger.info('Order creation started', {
userId: body.userId,
items: body.items.length,
})
try {
const order = await createOrder(body)
logger.info('Order created', {
orderId: order.id,
total: order.total,
})
return NextResponse.json(order, { status: 201 })
} catch (err: any) {
logger.error('Order creation failed', {
userId: body.userId,
error: err.message,
stack: err.stack,
})
return NextResponse.json({ error: 'Failed' }, { status: 500 })
}
}
Use it in Server Components:
// app/dashboard/page.tsx
import { logger } from '@/lib/logger'
export default async function DashboardPage() {
const data = await fetchDashboardData()
if (data.errors.length > 0) {
logger.warn('Dashboard loaded with errors', {
errorCount: data.errors.length,
})
}
return <Dashboard data={data} />
}
Pros: Works immediately, no Vercel configuration needed, works on all plans.
Cons: Manual — need to add logger calls to every route. Only captures what's explicitly logged, not raw stdout/stderr.
Vercel Log Drains forward all runtime and build logs to an external endpoint. This captures everything — console.log, console.error, build output, edge function logs.
vercel log-drains create https://api.getlogflow.com/v1/logs/vercel \
--type json \
--env production
Or via the Vercel dashboard: Settings → Log Drains → Add.
Each log drain delivery is a JSON array of events:
[
{
"id": "log_xxx",
"message": "Order created { orderId: 'ORD-9912' }",
"timestamp": 1692345678901,
"source": "lambda",
"projectId": "prj_xxx",
"deploymentId": "dpl_xxx",
"level": "info",
"path": "/api/orders",
"host": "my-app.vercel.app"
}
]
If the log platform doesn't have native Vercel drain support, build a lightweight receiver:
// A separate serverless function or external API
export async function POST(req: Request) {
const events = await req.json()
const logs = events.map((e: any) => ({
level: mapLevel(e.level),
message: e.message,
service: 'vercel',
timestamp: new Date(e.timestamp).toISOString(),
attributes: {
source: e.source,
path: e.path,
deploymentId: e.deploymentId,
host: e.host,
},
}))
await forwardToLogPlatform(logs)
return new Response('OK', { status: 200 })
}
function mapLevel(vercelLevel: string): string {
const map: Record<string, string> = {
info: 'info',
warning: 'warn',
error: 'error',
log: 'info',
}
return map[vercelLevel] || 'info'
}
Pros: Captures everything automatically (stdout, stderr, build logs). No code changes to the application.
Cons: Pro plan required ($20/month). Log format is Vercel-specific — needs transformation. Build logs add volume.
Pino is the fastest Node.js JSON logger. Combined with a custom transport, it gives structured logging with automatic shipping:
npm install pino pino-http
// lib/logger.ts
import pino from 'pino'
const transport = process.env.LOGFLOW_API_KEY
? pino.transport({
target: './lib/logflow-transport.mjs',
options: {
apiKey: process.env.LOGFLOW_API_KEY,
service: 'web',
},
})
: undefined
export const logger = pino(
{
level: process.env.LOG_LEVEL || 'info',
// Don't pretty-print in production
...(process.env.NODE_ENV === 'development' && {
transport: { target: 'pino-pretty' },
}),
},
transport
)
// lib/logflow-transport.mjs
import { Transform } from 'node:stream'
export default function (opts) {
const buffer = []
let timer = null
async function flush() {
if (buffer.length === 0) return
const batch = buffer.splice(0)
const payload = batch.map(entry => ({
level: levelToString(entry.level),
message: entry.msg || '',
service: opts.service,
attributes: Object.fromEntries(
Object.entries(entry)
.filter(([k]) => !['level','time','pid','hostname','msg','v'].includes(k))
.map(([k, v]) => [k, String(v)])
),
}))
try {
await fetch('https://api.getlogflow.com/v1/logs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${opts.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
} catch {}
}
function levelToString(n) {
if (n >= 60) return 'fatal'
if (n >= 50) return 'error'
if (n >= 40) return 'warn'
if (n >= 30) return 'info'
return 'debug'
}
return new Transform({
objectMode: true,
transform(chunk, enc, cb) {
try { buffer.push(JSON.parse(chunk)) } catch {}
if (!timer) timer = setTimeout(() => { flush(); timer = null }, 1000)
cb()
},
flush(cb) { flush().then(() => cb()) }
})
}
Use in API routes:
import { logger } from '@/lib/logger'
export async function GET(req: NextRequest) {
logger.info({ path: '/api/data', query: Object.fromEntries(req.nextUrl.searchParams) }, 'Data requested')
// ...
}
Next.js middleware runs on the Edge runtime, which has limitations (no Node.js fs, limited Buffer). Use the fetch-based approach:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(req: NextRequest) {
const start = Date.now()
const response = NextResponse.next()
// Fire-and-forget log (don't await in middleware hot path)
const duration = Date.now() - start
fetch('https://api.getlogflow.com/v1/logs', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.LOGFLOW_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify([{
level: 'info',
message: `Middleware: ${req.method} ${req.nextUrl.pathname}`,
service: 'web-edge',
attributes: {
method: req.method,
path: req.nextUrl.pathname,
geo_country: req.geo?.country || 'unknown',
durationMs: String(duration),
},
}]),
}).catch(() => {})
return response
}
Add to your Vercel project (Settings → Environment Variables):
LOGFLOW_API_KEY=lf_your_key_here
LOG_LEVEL=info
Set LOG_LEVEL=debug in Preview deployments for extra visibility during development.
| What | Level | Where |
|---|---|---|
| API route errors | error |
API route handlers |
| Failed external API calls | error |
Server components, API routes |
| Slow database queries (>2s) | warn |
Anywhere with DB access |
| Successful business events | info |
API routes (order created, user signed up) |
| Auth failures | warn |
Middleware, API routes |
| Page render times | debug |
Server components |
Don't log in client components — browser logs are visible to users in DevTools and aren't sent to the server. Use the @getlogflow/browser SDK for client-side error tracking.
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeGo Logging with Zerolog: Zero-Allocation Structured Logs
Set up zerolog in Go for fast structured JSON logging with request context and centralized shipping.
Java Logging with SLF4J and Logback: From Setup to Production
Set up structured JSON logging in Java with SLF4J, Logback, and MDC for request tracing.
How to Monitor Cron Jobs and Catch Silent Failures
Cron jobs fail silently by default. Set up logging and silence detection to catch failures before users do.