FormaTeX

\begin{article}

LaTeX PDF Generation in Node.js and TypeScript

Generate professional PDFs from LaTeX in Node.js — no system dependencies, no temp files, just an API call. Full TypeScript client with error handling and Next.js integration.

·5 min read·
LaTeX PDF Generation in Node.js and TypeScript

Node.js and TypeScript are the most common backend environments for web applications, and PDF generation is a nearly universal requirement. The traditional approach — spawning a pdflatex process — works on developer machines but falls apart in serverless environments, Docker containers without TeX Live, and anywhere you cannot install a 4 GB system dependency. The API approach solves all of this with zero system requirements.

The DIY Pain

The subprocess approach in Node.js:

typescript
import { exec } from "child_process";
import { promisify } from "util";
import { writeFile, readFile, mkdir, rm } from "fs/promises";
import { join } from "path";
import { tmpdir } from "os";

const execAsync = promisify(exec);

// This breaks in serverless, Docker without TeX Live, and CI without pdflatex
async function compilePdfNaive(latex: string): Promise<Buffer> {
  const dir = join(tmpdir(), `latex-${Date.now()}`);
  await mkdir(dir, { recursive: true });

  try {
    const texPath = join(dir, "document.tex");
    await writeFile(texPath, latex, "utf-8");

    await execAsync(
      `pdflatex -interaction=nonstopmode -output-directory "${dir}" "${texPath}"`
    );

    return await readFile(join(dir, "document.pdf"));
  } finally {
    await rm(dir, { recursive: true, force: true }); // cleanup
  }
}

Problems: requires pdflatex installed, single pass (no bibliography), breaks in serverless, error messages are raw TeX logs.

The fetch() Integration

The minimal API call — works in any Node.js environment:

typescript
async function compilePdf(latex: string): Promise<Buffer> {
  const response = await fetch("https://api.formatex.io/api/v1/compile", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.FORMATEX_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ content: latex, engine: "pdflatex" }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.log ?? error.error ?? "Compilation failed");
  }

  return Buffer.from(await response.arrayBuffer());
}

fetch is built into Node.js 18+. No npm package required.

Full TypeScript Client Class

A reusable client with proper typing, error handling, and engine selection:

typescript
type Engine = "pdflatex" | "xelatex" | "lualatex" | "latexmk";

interface CompileOptions {
  engine?: Engine;
}

interface CompileError {
  error: string;
  log?: string;
}

class FormaTeXClient {
  private readonly baseUrl = "https://api.formatex.io/api/v1";

  constructor(private readonly apiKey: string) {}

  async compile(latex: string, options: CompileOptions = {}): Promise<Buffer> {
    const engine = options.engine ?? "pdflatex";

    const response = await fetch(`${this.baseUrl}/compile`, {
      method: "POST",
      headers: {
        "X-API-Key": this.apiKey,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ content: latex, engine }),
    });

    if (!response.ok) {
      const error: CompileError = await response.json();
      throw new LatexError(error.error, error.log);
    }

    return Buffer.from(await response.arrayBuffer());
  }
}

class LatexError extends Error {
  constructor(
    message: string,
    public readonly log?: string
  ) {
    super(message);
    this.name = "LatexError";
  }

  get firstErrorLine(): string | undefined {
    return this.log?.split("\n").find((line) => line.startsWith("!"));
  }
}

// Usage
const formatex = new FormaTeXClient(process.env.FORMATEX_KEY!);

try {
  const pdf = await formatex.compile(latexSource, { engine: "xelatex" });
  await writeFile("output.pdf", pdf);
} catch (e) {
  if (e instanceof LatexError) {
    console.error("LaTeX error:", e.firstErrorLine ?? e.message);
    console.error("Full log:", e.log);
  }
  throw e;
}

Error Handling

LaTeX errors return 400 with the TeX compilation log. The log is verbose — extract the first ! line for the human-readable error:

typescript
function extractLatexError(log: string): string {
  const lines = log.split("\n");
  const errorLine = lines.find((l) => l.startsWith("!"));
  if (!errorLine) return log.slice(0, 200);

  // Also find the line number
  const lineNumberLine = lines
    .slice(lines.indexOf(errorLine))
    .find((l) => /^l\.\d+/.test(l));

  return lineNumberLine ? `${errorLine} (${lineNumberLine})` : errorLine;
}

Next.js API Route Example

A full Next.js App Router route that generates and streams a PDF:

typescript
// app/api/pdf/route.ts
import { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
  const { latexSource, engine = "pdflatex" } = await request.json();

  if (!latexSource) {
    return Response.json({ error: "latexSource is required" }, { status: 400 });
  }

  const compileResponse = await fetch(
    "https://api.formatex.io/api/v1/compile",
    {
      method: "POST",
      headers: {
        "X-API-Key": process.env.FORMATEX_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ content: latexSource, engine }),
    }
  );

  if (!compileResponse.ok) {
    const error = await compileResponse.json();
    return Response.json(
      { error: error.error, log: error.log },
      { status: 422 }
    );
  }

  // Stream the PDF directly to the client
  return new Response(compileResponse.body, {
    headers: {
      "Content-Type": "application/pdf",
      "Content-Disposition": 'attachment; filename="document.pdf"',
    },
  });
}

Streaming the response body directly (compileResponse.body) avoids buffering the entire PDF in memory on your server. For large documents, this significantly reduces memory usage.

Streaming Response

For large PDFs, stream the response to disk rather than buffering:

typescript
import { createWriteStream } from "fs";
import { pipeline } from "stream/promises";
import { Readable } from "stream";

async function compileToDisk(latex: string, outputPath: string): Promise<void> {
  const response = await fetch("https://api.formatex.io/api/v1/compile", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.FORMATEX_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ content: latex, engine: "pdflatex" }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.log ?? error.error);
  }

  await pipeline(
    Readable.fromWeb(response.body!),
    createWriteStream(outputPath)
  );
}

Beyond Sync Compilation

The examples above use the synchronous POST /compile endpoint. FormaTeX also offers:

  • Smart Compile (POST /compile/smart) — AI-powered error detection and auto-fix. If your LaTeX has errors, the AI pipeline fixes them automatically. See Smart Compile guide.
  • Async Compilation (POST /compile/async) — submit a job, get a job ID, poll for completion or receive a webhook. Ideal for long documents and batch processing. See Async guide.

Get Started

\end{article}

Back to blog

\related{posts}

One quick thing

We track anonymous usage — page views, feature usage, compilation events — to understand what works and what doesn't. No ads, no personal data, no third-party sharing.

Cookie policy