npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@nandan-varma/platex

v0.1.0

Published

Compile LaTeX to PDF binary with accurate output, with Next.js support

Readme

platex

npm version CI license

Compile LaTeX to PDF in TypeScript, with output as accurate as your local TeX toolchain. Works in any framework that speaks the Fetch API — Next.js, TanStack Start, Astro, SvelteKit, Remix, Bun, Deno, Cloudflare Workers — on Node.js or the edge.

Quick start

npm install @nandan-varma/platex

Create a client once, with your defaults baked in, and use it everywhere:

// lib/platex.ts
import { createPlatexClient } from '@nandan-varma/platex'

export const platex = createPlatexClient({
  serviceUrl: process.env.PLATEX_SERVICE_URL, // omit this line entirely — it's read automatically anyway
  timeout: 25_000,
})
// anywhere in your app
import { platex } from '@/lib/platex'

const result = await platex.compile(source)
if (result.pdf) { /* Buffer, ready to return/save/stream */ }

Or skip the client and drop a ready-made request handler straight into a route — it works identically in every framework below:

// app/api/compile/route.ts (Next.js) — see "Framework recipes" for Astro, TanStack Start, SvelteKit, Remix
import { handleCompileRequest } from '@nandan-varma/platex'

export const POST = handleCompileRequest

That's it — zero config needed if PLATEX_SERVICE_URL (and, if you've enabled auth, PLATEX_API_KEY) are set as environment variables. Every option below is optional; nothing is hardcoded.

Prefer the terminal? The same pipeline ships as a CLI:

npx @nandan-varma/platex main.tex          # → main.pdf, no TeX installation required

See CLI below for watch mode, extra files, remote compilation, and JSON output.

Documentation

Full documentation — installation, the compile() API, and every Next.js rendering pattern (Server Components, Client Components, Server Actions, Route Handlers) — lives in docs/, an Astro + Starlight site (all Markdown) ready to deploy to Vercel:

cd docs
npm install
npm run dev        # http://localhost:4321

The individual feature .tex files used as compilation fixtures (math, lists, tables, figures, bibliography, sectioning, code listings, hyperlinks) live in examples/tex/.

Architecture

┌─────────────────────────────────┐     HTTP POST /compile      ┌──────────────────────────────────┐
│  Your app (any framework,       │ ─────────────────────────▶  │  platex service (Vercel/Fly/      │
│  Node.js or edge)                │                              │  Railway/Render/self-hosted)      │
│                                 │                              │                                  │
│  import { createPlatexClient }  │ ◀─────────────────────────  │  Tectonic TeX engine (bundled    │
│    from '@nandan-varma/platex'  │        PDF binary            │  ~13MB binary, auto-downloads    │
│                                 │                              │  LaTeX packages on first use)    │
│  const platex = createPlatexClient({ ... })                    │                                  │
│  await platex.compile(source)   │                              │  POST /compile → runs TeX →      │
│                                 │                              │  returns PDF as base64 JSON      │
└─────────────────────────────────┘                              └──────────────────────────────────┘

Two deployment targets, same library

| Mode | When | Engine | Use case | |---|---|---|---| | Remote (recommended for Vercel/edge) | serviceUrl resolved | Tectonic (on the service) | Production, or any edge runtime | | Local | No serviceUrl, system TeX found | pdflatex / xelatex / lualatex | Self-hosted or dev with TeX Live | | Local fallback | No serviceUrl, no system TeX | Bundled Tectonic binary | Dev without TeX Live installed | | WASM/browser (platex/client only) | No serviceUrl, WASM-capable runtime (browsers, most edge runtimes) | pdflatex / xelatex / lualatex via bundled WASM TeX Live | Client-side compiling in the browser, or edge runtimes without a remote service |

The library auto-selects the engine — you don't configure this directly. serviceUrl resolves from the explicit option, falling back to PLATEX_SERVICE_URL.

Three entry points

| Import from | Runtime | What it's for | |---|---|---| | @nandan-varma/platex | Node.js | compile, createPlatexClient, handleCompileRequest — full library, local-compile fallback included | | @nandan-varma/platex/client | Anything with fetch — Vercel/Next.js Edge Runtime, Cloudflare Workers, Bun, Deno, browsers | Same client/handler API; remote when serviceUrl is configured, otherwise falls back to an in-browser/WASM TeX engine (see below) | | @nandan-varma/platex/server | Node.js | createApp, createCompileRoute — embed the compile HTTP API into your own server instead of running the standalone service |

platex/client never imports node:child_process/node:fs/node:os, so it's safe to bundle for edge deployments. If you call .compile() without a serviceUrl configured (and none in PLATEX_SERVICE_URL), it falls back to an in-browser/WASM TeX engine on WASM-capable runtimes (browsers, most edge runtimes — plain Node.js is deliberately excluded from this fallback, see below); it throws only when neither a serviceUrl nor WASM support is available.

// app/api/compile/route.ts — deployed to the Edge Runtime
export const runtime = 'edge'
import { createPlatexClient, handleCompileRequest } from '@nandan-varma/platex/client'

export const POST = handleCompileRequest // or: createRequestHandler(createPlatexClient({ ... }))

The WASM/browser fallback

When platex/client is used with no serviceUrl resolved, and the runtime is WASM-capable, it compiles in-process using texlyre-busytex (a WASM build of TeX Live), dynamically imported on first use so it's never pulled into your bundle unless this path actually runs. "WASM-capable" means typeof WebAssembly !== 'undefined' and not plain Node.js — Node also has a global WebAssembly, so it's explicitly excluded to avoid silently trying (and failing) to run a browser engine under vitest/node script.js/etc.

import { createPlatexClient } from '@nandan-varma/platex/client'

const platex = createPlatexClient({
  // no serviceUrl → falls back to WASM on WASM-capable runtimes
  wasm: {
    basePath: '/tex-assets',            // where busytex.wasm / busytex.js / texlive-extra.* are hosted
    dataPackages: ['latex', 'amsmath'], // optional: preload/catalog specific TeX Live packages
  },
})

const result = await platex.compile(source)
  • wasm.basePath is required for this path (or set PLATEX_WASM_BASE_PATH) — platex only needs to know where to fetch the WASM engine and its data files from. Hosting/staging those files (e.g. a CDN redirect for the large texlive-extra.data package) is entirely your app's responsibility, not this library's.
  • serviceUrl always wins when both are configured — WASM is a fallback, never a preference.
  • engine: 'tectonic' and bibliography: 'biber' aren't supported by this backend (texlyre-busytex doesn't ship either) and throw a TypeError immediately; use pdflatex/xelatex/lualatex and bibtex/none, or configure a serviceUrl for full Tectonic/biber support.
  • A single WASM runner instance is reused across calls in the same session (recreated only if wasm.basePath changes), so repeated compiles after the first are fast.

What Tectonic is

Tectonic is a self-contained TeX engine (~13 MB binary) based on XeTeX. Unlike pdflatex, it:

  • Bundles everything it needs — no separate TeX Live installation required
  • Automatically downloads missing LaTeX packages from its CDN on first use
  • Handles multi-pass compilation and bibliography internally (no manual bibtex runs)
  • Caches packages in /tmp on Vercel, making warm-container reuse fast

When system TeX Live is available (self-hosted Docker), the library uses pdflatex/xelatex/lualatex directly with full multi-pass control.


Setup

1. Deploy the platex service

The service is a standalone project that does the actual LaTeX compilation. Deploy it anywhere that runs Node.js — Vercel, Fly.io, Railway, Render, or your own Docker host.

git clone https://github.com/nandan-varma/platex
cd platex
npx vercel deploy

Vercel runs npm run build:vercel, which downloads the Tectonic binary for Linux x86_64 and packs it into the serverless function via includeFiles: "bin/**" in vercel.json. Your service is now live at something like https://platex-xxx.vercel.app.

2. Install the client library

npm install @nandan-varma/platex

3. Set environment variables

PLATEX_SERVICE_URL=https://your-platex-service.vercel.app
# PLATEX_API_KEY=...     # only if you enabled auth on the service — see "Server configuration"

Both createPlatexClient() and the plain compile()/handleCompileRequest read these automatically — you never have to thread process.env.PLATEX_SERVICE_URL through your own code.


Usage

The recommended pattern: one client, reused everywhere

// lib/platex.ts
import { createPlatexClient } from '@nandan-varma/platex'

export const platex = createPlatexClient({
  timeout: 25_000,
  retry: 2,          // retry transient network/5xx failures against the service
  // engine, passes, bibliography, limits, apiKey, headers... all optional, all overridable per call
})
// app/api/compile/route.ts
import { NextResponse } from 'next/server'
import { platex } from '@/lib/platex'

export const runtime = 'nodejs'
export const maxDuration = 30

export async function POST(req: Request) {
  const { source } = await req.json()
  const result = await platex.compile(source)

  if (!result.pdf) {
    return NextResponse.json({ errors: result.errors }, { status: 422 })
  }
  return new NextResponse(result.pdf, { headers: { 'Content-Type': 'application/pdf' } })
}

createPlatexClient returns plain functions — const { compile } = platex works fine, no this binding required.

Even less code: handleCompileRequest

If your route just takes { source, ... } and returns a PDF, skip writing the handler yourself:

import { handleCompileRequest } from '@nandan-varma/platex'
export const POST = handleCompileRequest

It parses the JSON body, calls compile() (or your client, via createRequestHandler), and returns a Response — raw PDF bytes with Content-Type: application/pdf on success, or a JSON { errors, warnings } body at 422 on compile failure, 400 for bad input, 502 if the remote service is unreachable. See Framework recipes below for the exact snippet per framework, and handleCompileRequest reference for all options.

Server Actions

// app/actions/compile.ts
'use server'
import { platex } from '@/lib/platex'

export async function compileLatex(source: string) {
  const result = await platex.compile(source)
  if (!result.pdf) return { ok: false, errors: result.errors }
  // Buffers aren't serializable across the server/client boundary — use base64
  return { ok: true, pdf: result.pdf.toString('base64') }
}

With additional files (.bib, images)

import { readFile } from 'fs/promises'
import { platex } from '@/lib/platex'

const result = await platex.compile(source, {
  bibliography: 'bibtex',
  files: {
    'refs.bib': await readFile('refs.bib'),
    'figures/logo.png': await readFile('logo.png'),
  },
})

Cancelling an in-flight compile

const controller = new AbortController()
const result = platex.compile(source, { signal: controller.signal })
// ...later
controller.abort()

CLI

The package ships a platex binary — the exact same compile pipeline as the library (local TeX Live → bundled Tectonic fallback → or remote service), driveable from any shell or script:

npx @nandan-varma/platex main.tex                 # → main.pdf next to the input
platex main.tex -o out/paper.pdf                  # custom output path (dirs created)
platex main.tex -w                                # watch mode: recompile on save
platex main.tex -f refs.bib -f figures/           # attach files or whole directories
platex main.tex -e xelatex -p 3 -b biber          # engine / passes / bibliography
platex main.tex -s https://platex.example.com     # compile via a remote service
echo '\documentclass{article}...' | platex -      # read source from stdin
platex main.tex --json | jq '.errors'             # full CompileResult as JSON

| Flag | Description | |---|---| | -o, --output <path> | Output PDF path (default: input path with .pdf) | | -e, --engine <name> | pdflatex | xelatex | lualatex | tectonic | | -p, --passes <n> | auto | 1 | 2 | 3 | | -b, --bib <name> | bibtex | biber | none | | -f, --file <path> | Attach an extra file or directory (repeatable). Files are keyed relative to the input's directory; directories are walked recursively | | -t, --timeout <ms> | Overall wall-clock budget for the whole pipeline | | -s, --service-url <url> | Compile remotely (default: PLATEX_SERVICE_URL env var) | | --api-key <key> | Bearer token for the service (default: PLATEX_API_KEY env var) | | --retry <n> | Extra attempts on retryable remote failures | | -w, --watch | Recompile whenever the input or attached files change | | --json | Print the full CompileResult as JSON on stdout (pdf base64-encoded); writes the PDF file only if -o is given | | -q, --quiet | Only print errors |

Exit codes: 0 success, 1 compile failed (errors are printed with file:line locations), 2 usage or environment error. Ctrl-C aborts the in-flight compile cleanly — no orphaned TeX processes.


Framework recipes

handleCompileRequest takes a standard Fetch API Request and returns a Response — the same function works everywhere below without modification. Use @nandan-varma/platex for Node.js routes (with local-compile fallback) or @nandan-varma/platex/client for edge routes (remote-only).

Next.js (App Router)

// app/api/compile/route.ts
import { handleCompileRequest } from '@nandan-varma/platex'
export const runtime = 'nodejs'
export const POST = handleCompileRequest

TanStack Start

// app/routes/api/compile.ts
import { createServerFileRoute } from '@tanstack/react-start/server'
import { handleCompileRequest } from '@nandan-varma/platex'

export const Route = createServerFileRoute('/api/compile').methods({
  POST: ({ request }) => handleCompileRequest(request),
})

Astro

// src/pages/api/compile.ts
import type { APIRoute } from 'astro'
import { handleCompileRequest } from '@nandan-varma/platex'

export const POST: APIRoute = ({ request }) => handleCompileRequest(request)

SvelteKit

// src/routes/api/compile/+server.ts
import { handleCompileRequest } from '@nandan-varma/platex'
export const POST = ({ request }) => handleCompileRequest(request)

Remix

// app/routes/api.compile.ts
import { handleCompileRequest } from '@nandan-varma/platex'
export async function action({ request }: { request: Request }) {
  return handleCompileRequest(request)
}

Bun / Deno / Hono / Cloudflare Workers — anything with a Request in, Response out handler:

import { handleCompileRequest } from '@nandan-varma/platex/client' // or 'platex' on Node/Bun/Deno for local fallback
Bun.serve({ fetch: (req) => handleCompileRequest(req) })

Want your client's defaults (custom timeout, apiKey, retry, ...) applied without repeating them? Bind the handler to a client once:

import { createPlatexClient, createRequestHandler } from '@nandan-varma/platex'

const platex = createPlatexClient({ timeout: 25_000, retry: 2 })
export const POST = createRequestHandler(platex)

Configuration reference

Nothing below is hardcoded — every default can be overridden per call, per client, or (for the server) per deployment.

CompileOptions (per-call, also accepted by createPlatexClient's config as defaults)

| Option | Type | Default | Description | |---|---|---|---| | engine | 'pdflatex' \| 'xelatex' \| 'lualatex' | 'pdflatex' | TeX engine (used when system TeX is available; Tectonic is always XeTeX-based) | | passes | 'auto' \| 1 \| 2 \| 3 | 'auto' | Compilation passes. 'auto' reruns until output is stable | | bibliography | 'bibtex' \| 'biber' \| 'none' | 'bibtex' | Bibliography engine | | files | Record<string, Buffer \| Uint8Array> | {} | Additional files: .bib, images, included .tex files | | serviceUrl | string | PLATEX_SERVICE_URL env var | URL of the platex service. If unset (and no env var), compiles locally (platex entry) or via WASM (platex/client entry, see WASM/browser fallback) | | wasm | { basePath?: string; dataPackages?: string[] } | basePath from PLATEX_WASM_BASE_PATH env var | platex/client-only. Configures the WASM/browser fallback engine's asset location and preloaded TeX Live packages | | apiKey | string | PLATEX_API_KEY env var | Sent as Authorization: Bearer <apiKey> to the service. Pairs with the service's own PLATEX_API_KEY | | headers | Record<string, string> | {} | Extra headers merged into the remote request | | timeout | number | 30000 | Overall wall-clock budget in milliseconds for the entire compile pipeline (all LaTeX passes plus bibliography combined for local; the whole HTTP round-trip for remote) — not per-process | | limits | CompileLimits | see below | Override input-size ceilings for this call | | retry | number | 0 | Extra attempts for the remote path on retryable failures (network error, our own timeout, or a 5xx). 4xx and caller-cancelled requests are never retried | | fetch | typeof fetch | global fetch | Custom fetch implementation for the remote path | | signal | AbortSignal | — | Cancel an in-flight compile (kills local subprocesses, or aborts the remote HTTP request) |

CompileLimits

| Field | Default | Description | |---|---|---| | maxSourceBytes | 5_000_000 | Max size of source, in UTF-8 bytes | | maxFilesCount | 50 | Max number of entries in files | | maxTotalFilesBytes | 25_000_000 | Max combined decoded size of all files entries |

// A client with a bigger budget for large multi-chapter documents
const platex = createPlatexClient({
  limits: { maxSourceBytes: 20_000_000, maxTotalFilesBytes: 100_000_000 },
})

Passing limits to a remote call is a client-side convenience only (it changes what your own app will accept before even sending the request) — it cannot raise what the server enforces. Configure the server's own limits when you deploy it; see Server configuration.

createPlatexClient(config?): PlatexClient

config accepts everything in CompileOptions above except files and signal (those only make sense per call). Returns:

interface PlatexClient {
  compile(source: string, options?: CompileOptions): Promise<CompileResult>
  health(): Promise<boolean>  // pings GET <serviceUrl>/health; true immediately if no serviceUrl configured
}

handleCompileRequest(request, options?)

options is CompileOptions plus:

| Option | Type | Default | Description | |---|---|---|---| | responseFormat | 'pdf' \| 'json' | 'pdf' | 'pdf': raw PDF bytes on success, JSON {errors, warnings} at 422 on failure. 'json': always 200 with { pdf: base64 \| null, errors, warnings } |

Request body: { source: string, engine?, passes?, bibliography?, files?: Record<string, base64>, timeout? } — only source is required.

createRequestHandler(client) binds the same behavior to a specific PlatexClient instead of the default env-var-driven one.


CompileResult

interface CompileResult {
  pdf: Buffer | null        // null on fatal compile error
  errors: LatexError[]      // structured errors with file + line number
  warnings: LatexWarning[]  // overfull boxes, undefined refs, etc.
  logs: RawPassLog[]        // per-pass raw .log content for debugging; each entry has a `timedOut` flag
}

interface LatexError {
  type: 'error'
  file: string | null
  line: number | null
  message: string
  context: string | null    // surrounding lines from the TeX log
  source: 'latex' | 'bibtex' | 'biber'
}

interface LatexWarning {
  type: 'warning'
  code: 'overfull-hbox' | 'underfull-hbox' | 'undefined-reference' | 'undefined-citation' | ...
  file: string | null
  line: number | null
  message: string
}

Self-hosted (Docker, maximum accuracy)

If you're self-hosting (not on Vercel), the Docker image uses full TeX Live:

# Build the service image
npm run build:server
docker build -f docker/Dockerfile -t platex .

# Run it
docker run -p 3001:3001 platex

# Or with docker compose
docker compose -f docker/docker-compose.yml up

Then set PLATEX_SERVICE_URL=http://localhost:3001 in your app. With full TeX Live, pdflatex/xelatex/lualatex all run natively with standard flags and multi-pass logic.


Development without TeX installed

Run the service locally via Docker (above), or let the library use the bundled Tectonic:

# Download tectonic for your platform (macOS or Linux)
node scripts/download-tectonic.mjs

# Now compile without any other TeX installation
// No serviceUrl → uses local tectonic binary automatically
const result = await compile(source)

Service endpoints

| Method | Path | Description | |---|---|---| | POST | /compile | Compile LaTeX. Body: CompileRequest JSON. Response: CompileResponse JSON | | GET | /health | Health check. Returns { "status": "ok" } (never requires auth) |

CompileRequest body shape:

{
  source: string           // LaTeX source (main.tex content)
  engine: 'pdflatex' | 'xelatex' | 'lualatex'
  passes: 'auto' | 1 | 2 | 3
  bibliography: 'bibtex' | 'biber' | 'none'
  files: Record<string, string>   // filename → base64-encoded content
  timeout: number                 // milliseconds, overall pipeline budget (see above), capped at 120s
}

Server configuration

The standalone server (npm run dev, the Docker image, or node dist/server.cjs) is zero-config by default — it reads these env vars, so a deployment needs nothing but docker run. If you're embedding the app yourself (e.g. a custom Node entrypoint), pass the same settings programmatically to createApp() instead:

import { createApp } from '@nandan-varma/platex/server'
import { serve } from '@hono/node-server'

const app = createApp({
  apiKey: process.env.PLATEX_API_KEY,             // or read from your own secrets manager
  maxConcurrentCompiles: 8,                        // scale with your container's CPU count
  limits: { maxSourceBytes: 20_000_000 },
  maxRequestBodyBytes: 60_000_000,                 // auto-derived from `limits` if omitted
})
serve({ fetch: app.fetch, port: 3001 })

| Setting | Env var | Default | Description | |---|---|---|---| | apiKey | PLATEX_API_KEY | unset (no auth) | If set, POST /compile requires Authorization: Bearer <key>. Set this (or otherwise restrict network access) before exposing the service publicly — compiling is CPU-intensive | | maxConcurrentCompiles | PLATEX_MAX_CONCURRENT | 4 | Max simultaneous compiles this instance runs; additional requests get 503 until a slot frees up | | limits | — | see CompileLimits | Input-size ceilings enforced for every request this deployment accepts — a client's own limits option can never raise these | | maxRequestBodyBytes | — | derived from limits + margin | Raw request body cap (413 if exceeded), checked before JSON parsing | | PORT | PORT | 3001 | Port the standalone server listens on |

createCompileRoute(config) (same maxConcurrentCompiles/limits options) is also exported if you want to mount just the /compile route into your own Hono app instead of using the whole createApp().


How output accuracy works

When running with system TeX Live (Docker/self-hosted):

  • Same engine flags: -interaction=nonstopmode -halt-on-error -file-line-error
  • Same multi-pass logic: detects \citation{} in .aux → runs bibtex → re-runs LaTeX
  • Same rerun patterns: Rerun to get cross-references right, Label(s) may have changed, hyperref outlines, natbib, longtable
  • Same Docker base image: texlive/texlive:latest (official TeX Users Group image, full TeX Live)

When running on Vercel with Tectonic, output is XeTeX-based. Nearly identical for most documents; minor differences possible in documents that rely on pdflatex-specific font metrics.


Contributing

See CONTRIBUTING.md for the development workflow and CHANGELOG.md for release history. Issues and PRs welcome.