I’m working on a Next.js 15 project where I generate images dynamically. The implementation works fine on localhost, but after deploying to Vercel, the generated image contains blank boxes instead of text.
Even when using a system font like “Arial”, the issue persists.
Here is the relevant code snippet:
'use server'
import sharp from 'sharp'
import path from 'path'
interface CreateArgs {
nomineName: string
awardName: string
regionName: string
}
// Estimate font size so text fits in 1 line and does not exceed SVG width
function getNomineFontSize(name: string, maxFont = 48, minFont = 16, maxWidth = 380) {
// Average character width factor (adjust for your font)
const avgCharWidth = 0.6 // 0.6em per character is a good estimate for most sans-serif fonts
const estimatedFontSize = Math.floor(maxWidth / (name.length * avgCharWidth))
return Math.max(minFont, Math.min(maxFont, estimatedFontSize))
}
// Helper to wrap text at a max character length per line, maxLines = 2
function wrapText(text: string, maxChars = 20, maxLines = 2) {
const words = text.split(' ')
const lines: string[] = []
let currentLine = ''
for (const word of words) {
if ((currentLine + ' ' + word).trim().length > maxChars) {
lines.push(currentLine.trim())
currentLine = word
if (lines.length === maxLines - 1) {
// Add the rest of the words to the last line and break
currentLine += ' ' + words.slice(words.indexOf(word) + 1).join(' ')
break
}
} else {
currentLine += ' ' + word
}
}
if (currentLine) lines.push(currentLine.trim())
// Ensure no more than maxLines
return lines.slice(0, maxLines)
}
// Estimate font size for award lines so each line fits in maxWidth (e.g. 390px)
function getAwardFontSize(lines: string[], maxFont = 28, minFont = 14, maxWidth = 390) {
// Find the longest line
const longest = lines.reduce((a, b) => (a.length > b.length ? a : b), '')
const avgCharWidth = 0.6
const estimatedFontSize = Math.floor(maxWidth / (longest.length * avgCharWidth))
return Math.max(minFont, Math.min(maxFont, estimatedFontSize))
}
export const createBadge = async ({
nomineName,
awardName,
regionName
}: CreateArgs) => {
try {
const rootPath = process.cwd()
const imagePath = path.resolve(rootPath, './public/badge/badge.png') // Path to your image
const image = sharp(imagePath)
// Nominee name font size (1 line, full width)
const nomineFontSize = getNomineFontSize(nomineName)
// Award name: wrap to 2 lines, font size so each line fits maxWidth
const awardLines = wrapText(awardName, 20, 2)
const awardFontSize = getAwardFontSize(awardLines)
const awardTspans = awardLines
.map((line, i) => `<tspan x="50%" dy="${i === 0 ? 0 : 28}">${line}</tspan>`)
.join('')
const svgText = `
<svg width="440" height="440">
<style>
.nomine { fill: #ba8f30; font-size: ${nomineFontSize}px; font-weight: bold; font-family: "Arial", sans-serif; }
.region { fill: #ba8f30; font-size: 40px; font-weight: bold; font-family: "Arial", sans-serif; }
.award { fill: white; font-size: ${awardFontSize}px; font-weight: bold; font-family: "Arial", sans-serif; }
</style>
<text
x="50%"
y="200px"
alignment-baseline="middle"
text-anchor="middle"
class="nomine"
>${nomineName}</text>
<text x="50%" y="255px" alignment-baseline="middle" text-anchor="middle" class="region">${regionName}</text>
<text x="50%" y="380px" alignment-baseline="middle" text-anchor="middle" class="award">
${awardTspans}
</text>
</svg>
`
const buffer = await image
.composite([{ input: Buffer.from(svgText), top: 30, left: 30 }])
.toBuffer()
return { buffer}
} catch (error) {
console.error('Error generating badge:', error)
throw error // Rethrow the error to be handled by the caller
}
}
Troubleshooting Attempts:
- Works correctly on localhost
- Tried using system fonts (e.g., Arial)
- Issue persists only on Vercel deployment
I’ve attached images from both localhost and Vercel to show the difference.
Has anyone faced a similar issue or found a solution? Any insights would be appreciated!
Thanks in advance.

