A self-hosted Next.js API that generates pixel-perfect LinkedIn post PNG images from a JSON payload. Send a POST request, get a PNG back.
The API renders a complete LinkedIn post β profile header, formatted body text, reaction counters, action bar β and returns a high-resolution PNG image. It supports:
- Lightweight markdown (bold, italic, links, hashtags)
- Emoji rendering via SVG sprites (Twemoji / Noto Color Emoji)
- Platform-native typography (Windows β Segoe UI, macOS/iOS β SF Pro, Android β Roboto)
- Responsive device layouts (mobile 800px / tablet 1000px / desktop 1200px)
- LinkedIn-style image gallery (1β4+ attachments with overflow badge)
- Fully customizable color themes
- Auto-height calculation based on content length
- Preview modes: full post (
more) or truncated with "β¦more" suffix (less) - Three profile picture input methods: remote URL, inline SVG, or public file path
| Tool | Role |
|---|---|
| Next.js 15 | API framework (Node.js runtime) |
| Satori | React component tree β SVG |
| @resvg/resvg-js | SVG β PNG |
| TypeScript | Type safety |
| Bundled fonts | Inter, SF Pro Text, Segoe UI, Roboto, Noto Emoji |
docker compose up -dThe API is available at http://localhost:3000/api/linkedin-image.
VPS deployment (behind Traefik): Create a docker-compose.override.yml next to this repo's docker-compose.yml on your server β Docker Compose merges it automatically:
services:
app:
ports: []
networks:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.linkedin.rule=Host(`your-subdomain.yourdomain.com`)"
- "traefik.http.routers.linkedin.entrypoints=websecure"
- "traefik.http.routers.linkedin.tls.certresolver=letsencrypt"
networks:
traefik-public:
external: trueThis file stays on your server β never commit it.
- Node.js 18+
- npm
git clone https://github.com/your-username/linkedin-post-generator.git
cd linkedin-post-generator
npm install
npm run devThe API is available at http://localhost:3000/api/linkedin-image.
curl http://localhost:3000/api/linkedin-image
# {"ok":true,"message":"POST an object to get a PNG back."}curl -sS -X POST http://localhost:3000/api/linkedin-image \
-H "Content-Type: application/json" \
-d '{
"firstName": "Alex",
"lastName": "Martin",
"headline": "Software Engineer @ Acme Corp",
"textMarkdown": "Excited to share my **new open-source project** π\n\nBuilt with #NextJS and #TypeScript.\n\n[Check it out on GitHub](https://github.com)",
"reactions": 247,
"comments": 38,
"reposts": 12
}' \
--output my-post.pngReturns a PNG image (binary, Content-Type: image/png).
| Field | Type | Description |
|---|---|---|
firstName |
string |
Author first name |
lastName |
string |
Author last name |
textMarkdown |
string |
Post body. Supports **bold**, *italic*, [text](url), #hashtag, \n line breaks |
| Field | Type | Description |
|---|---|---|
profileImageUrl |
string |
Remote image URL β fetched and embedded as base64 |
profileSvgMarkup |
string |
Raw SVG XML string |
profileSvgPublicPath |
string |
Path to a file inside /public (e.g. "icons/avatar-default.svg") |
| Field | Type | Default | Description |
|---|---|---|---|
headline |
string |
"" |
Job title shown under the name |
timeAgo |
string |
"β’ 1 h" |
Time label (e.g. "2 h", "3 d") |
reactions |
number |
0 |
Reaction count |
comments |
number |
0 |
Comment count |
reposts |
number |
0 |
Repost count |
| Field | Type | Default | Description |
|---|---|---|---|
platformStyle |
"windows" | "mac" | "ios" | "android" |
"windows" |
Font stack |
devicePreview |
"mobile" | "tablet" | "desktop" |
"desktop" |
Width preset (800 / 1000 / 1200 px) |
typePreview |
"more" | "less" |
"more" |
"less" truncates text to ~3 visible lines |
size.width |
number |
device-dependent | Custom width in px (600β2000) |
size.height |
number | "auto" |
"auto" |
Custom height in px (800β4000) or "auto" |
All fields are optional CSS color strings (hex, rgba(), etc.).
| Field | Default | Description |
|---|---|---|
theme.background |
"#EEF2F5" |
Page background |
theme.card |
"#FFFFFF" |
Post card background |
theme.text |
"#000000e6" |
Primary text |
theme.subtext |
"#00000099" |
Secondary text (headline, time, counters) |
theme.divider |
"#E5E7EB" |
Divider line above the action bar |
| Field | Type | Description |
|---|---|---|
attachmentsUrls |
string[] |
Remote image URLs β fetched and embedded |
attachmentsData |
string[] |
Already-encoded base64 data URLs |
Attachment grid layouts:
| Count | Layout |
|---|---|
| 1 | Full width |
| 2 | 50 / 50 side-by-side |
| 3 | 1 large left + 2 stacked right |
| 4+ | 2Γ2 grid β excess shown as +N badge |
| Status | Body | Meaning |
|---|---|---|
200 |
image/png binary |
Success |
400 |
{ "error": string } |
Missing or invalid fields |
500 |
{ "error": string, "detail": string } |
Server-side error |
curl -sS -X POST http://localhost:3000/api/linkedin-image \
-H "Content-Type: application/json" \
-d '{
"firstName": "Alex",
"lastName": "Martin",
"textMarkdown": "Dark mode LinkedIn post π",
"theme": {
"background": "#1A1A2E",
"card": "#16213E",
"text": "#E0E0E0",
"subtext": "#9E9E9E",
"divider": "#2A2A4A"
}
}' \
--output dark-theme.pngcurl -sS -X POST http://localhost:3000/api/linkedin-image \
-H "Content-Type: application/json" \
-d '{
"firstName": "Alex",
"lastName": "Martin",
"textMarkdown": "A very long post that gets cut off in the preview...\n\nThis part will not be visible.\n\nNeither will this.",
"devicePreview": "mobile",
"typePreview": "less"
}' \
--output mobile-truncated.pngcurl -sS -X POST http://localhost:3000/api/linkedin-image \
-H "Content-Type: application/json" \
-d '{
"firstName": "Alex",
"lastName": "Martin",
"textMarkdown": "Four screenshots from the project π",
"size": { "height": "auto" },
"attachmentsUrls": [
"https://picsum.photos/seed/a/800/600",
"https://picsum.photos/seed/b/800/600",
"https://picsum.photos/seed/c/800/600",
"https://picsum.photos/seed/d/800/600"
]
}' \
--output gallery.pngSee the examples/ folder for ready-to-use JSON payloads covering every major feature.
The repo ships with a script that calls the API once per feature and writes labelled PNGs into test-img/. Start the dev server first, then run:
npm run dev & # start the API
npm run test:images # generate ~25 images in test-img/Each output file is named after what it demonstrates:
test-img/
basic-post.png
markdown-bold-italic.png
markdown-links.png
markdown-hashtags.png
markdown-combined.png
emoji-rendering.png
line-breaks.png
platform-windows.png
platform-mac.png
platform-android.png
device-mobile.png
device-tablet.png
device-desktop.png
preview-more.png
preview-less.png
auto-height.png
reactions-counts.png
custom-theme-dark.png
avatar-remote-url.png
avatar-inline-svg.png
avatar-default.png
attachments-1-image.png
attachments-2-images.png
attachments-3-images.png
attachments-4-plus-images.png
The API_URL environment variable overrides the default http://localhost:3000:
API_URL=https://my-deployment.example.com npm run test:images| Feature | Preview |
|---|---|
| Basic post | ![]() |
| Emoji rendering | ![]() |
| Markdown (links, hashtags) | ![]() |
| Preview mode: less | ![]() |
| Reactions & action bar | ![]() |
| Auto height | ![]() |
src/
app/api/linkedin-image/
route.ts β API handler, validation, orchestration
lib/og/
post.ts β LinkedIn post React component (Satori input)
markdown.ts β Markdown parser and emoji handling
theme.ts β Color palette utilities
fonts.ts β Font loading from /public/fonts/
fontStacks.ts β Platform-specific font fallback chains
icons.ts β SVG icon definitions
reactions.ts β Reaction badge renderer
actions.ts β Action bar (Like / Comment / Repost / Send)
public/
fonts/ β Bundled Inter, SF Pro Text, Segoe UI, Roboto, Noto Emoji
icons/ β Default avatar and reaction SVGs
emoji/ β Twemoji and Noto Color Emoji SVG sprites
examples/ β Ready-to-use JSON payloads
scripts/
generate-test-images.sh β Test image generation script
test-img/ β Sample PNG outputs (generated by the script above)
MIT β see LICENSE.





