DEV Community

Khatai Huseynzada
Khatai Huseynzada

Posted on

How to render chess diagrams in Node.js (no DOM, no canvas, no headless browser)

If you've ever needed to generate a chess diagram programmatically — for a blog, a PDF report, or a static site — you've probably hit the same wall I did.

Most solutions require either a browser environment, a headless Chromium instance, or a canvas polyfill that pulls in half of npm. For something as simple as "turn a FEN string into an image," that's a lot of overhead.

So I extracted the rendering core from my chess editor into a standalone package: @chessvision-org/chess-vision. Zero dependencies. Works in Node.js, Deno, Bun, and the browser. No DOM, no canvas, no network calls.

The basics

npm install @chessvision-org/chess-vision
Enter fullscreen mode Exit fullscreen mode
import { generateDiagram } from '@chessvision-org/chess-vision';

const svg = generateDiagram({
  fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1',
  size: 400,
  showCoords: true,
});
Enter fullscreen mode Exit fullscreen mode

svg is a self-contained SVG string — inline it in HTML, write it to a file, embed it in a PDF. Pieces are inlined as SVG paths (CBurnett / Lichess style), so no external resources are needed.

generateDiagram throws if the FEN string is invalid. Validate first if you're working with untrusted input:

import { generateDiagram, validateFEN, getFENValidationError } from '@chessvision-org/chess-vision';

if (!validateFEN(fen)) {
  console.error(getFENValidationError(fen)); // e.g. 'Board must have 8 ranks'
} else {
  const svg = generateDiagram({ fen, size: 400 });
}
Enter fullscreen mode Exit fullscreen mode

Real use cases

Static site or blog

If you write about chess and want diagrams in your posts, generate them at build time:

import { writeFileSync } from 'fs';
import { generateDiagram } from '@chessvision-org/chess-vision';

const positions = [
  {
    fen: 'r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4',
    file: 'italian.svg',
  },
  {
    fen: 'rnbqkb1r/pp3ppp/3ppn2/8/3NP3/2N5/PPP2PPP/R1BQKB1R w KQkq - 0 6',
    file: 'sicilian.svg',
  },
];

for (const { fen, file } of positions) {
  writeFileSync(`./diagrams/${file}`, generateDiagram({ fen, size: 480, showCoords: true }));
}
Enter fullscreen mode Exit fullscreen mode

The output SVGs embed directly in Markdown, MDX, or HTML with no image hosting needed.

Server-side rendering

Generate diagrams on request without spinning up a browser:

import express from 'express';
import { generateDiagram, validateFEN } from '@chessvision-org/chess-vision';

const app = express();

app.get('/diagram', (req, res) => {
  const fen = String(req.query.fen ?? '');
  if (!validateFEN(fen)) return res.status(400).send('Invalid FEN');

  const svg = generateDiagram({ fen, size: 400, showCoords: true });
  res.setHeader('Content-Type', 'image/svg+xml');
  res.send(svg);
});
Enter fullscreen mode Exit fullscreen mode

Astro / Next.js at build time

// In an Astro component or Next.js getStaticProps
import { generateDiagram } from '@chessvision-org/chess-vision';

const svg = generateDiagram({
  fen: 'r1bqkbnr/pppp1ppp/2n5/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R w KQkq - 2 3',
  size: 360,
  showCoords: true,
  flipped: false,
});

// Render directly — no img tag, no src, no upload
// <Fragment set:html={svg} />   ← Astro
// <div dangerouslySetInnerHTML={{ __html: svg }} />  ← React (SVG is from your own code, not user input)
Enter fullscreen mode Exit fullscreen mode

Themes and customization

The package ships 20 built-in board themes (classic, ocean, wood, forest, navy, marble, and more):

import { generateDiagram, getBoardTheme, listThemeIds, themeCoordinateColor } from '@chessvision-org/chess-vision';

listThemeIds();
// → ['classic', 'brown', 'wood', 'sand', 'slate', 'marble', 'blue', 'ocean',
//    'green', 'forest', 'mint', 'purple', 'lavender', 'red', 'coral',
//    'sunset', 'pink', 'burgundy', 'navy', 'ice']

const theme = getBoardTheme('ocean')!;
// → { name: 'Ocean', light: '#c9e4f5', dark: '#4a90a4' }

const svg = generateDiagram({
  fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
  size: 400,
  lightSquare: theme.light,
  darkSquare: theme.dark,
  showCoords: true,
  coordColor: themeCoordinateColor(theme), // → 'white' or 'black', whichever is more legible
});
Enter fullscreen mode Exit fullscreen mode

Or pass any hex colors directly:

const svg = generateDiagram({
  fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1',
  size: 400,
  lightSquare: '#e8f4f8',
  darkSquare: '#7eb8da',
  flipped: true,       // Black's perspective
  showFrame: true,     // thin outer frame
  coordColor: 'white', // coordinate label color
  label: 'Starting position', // aria-label on the SVG
});
Enter fullscreen mode Exit fullscreen mode

FEN utilities

The package also ships a full FEN parser if you need to work with positions directly:

import {
  parseFEN,
  boardToFEN,
  movePiece,
  getPieceAt,
  countPieces,
  materialBalance,
  findKing,
  STARTING_FEN,
} from '@chessvision-org/chess-vision';

// Parse to an 8×8 matrix (row 0 = rank 8, row 7 = rank 1)
const board = parseFEN(STARTING_FEN);
board[7][4]; // → 'K'  (white king on e1)

getPieceAt(board, 'e1'); // → 'K'
getPieceAt(board, 'd8'); // → 'q'

// Move a piece — pure, returns a new board, never mutates
const next = movePiece(board, 'e2', 'e4');
boardToFEN(next); // → updated piece placement string

countPieces(board);    // → { P: 8, N: 2, B: 2, R: 2, Q: 1, K: 1, p: 8, … }
materialBalance(board); // → 0  (equal material)
findKing(board, 'w');  // → 'e1'
Enter fullscreen mode Exit fullscreen mode

Full FEN record

Parse and serialize all six FEN fields:

import { parseFENRecord, buildFENRecord, toggleActiveColor } from '@chessvision-org/chess-vision';

const record = parseFENRecord('rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 5 12');
// → { board, activeColor: 'b', castling: 'KQkq', enPassant: 'e3', halfmove: 5, fullmove: 12 }

buildFENRecord(record);
// → 'rnbqkbnr/pp1ppppp/8/2p5/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 5 12'

toggleActiveColor(record).activeColor; // → 'w'
Enter fullscreen mode Exit fullscreen mode

Why SVG

  • Scales to any resolution without quality loss
  • Self-contained — 23 piece sets inlined as paths, no external resources
  • Embeds directly in HTML and most PDF libraries
  • Converts to raster easily with sharp if you need PNG

If you need high-res PNG for print, the web app (chessvision.org) handles that — up to 1200 DPI with accurate physical dimensions in centimeters.

Links

Open to feedback on the API — if something feels awkward to use or there's a use case I haven't covered, I'd like to know.

Top comments (0)