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 freeLogging in Python with LogFlow
Add production-ready structured logging to any Python application and ship logs to LogFlow.
Structured Logging in Flask
Add production-ready structured logging to any Flask app in under 10 minutes.
Structured Logging in Laravel
Add production-ready structured logging to Laravel and ship logs to LogFlow.