@nandan-varma/platex
v0.1.0
Published
Compile LaTeX to PDF binary with accurate output, with Next.js support
Maintainers
Readme
platex
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/platexCreate 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 = handleCompileRequestThat'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 requiredSee 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:4321The 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.basePathis required for this path (or setPLATEX_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 largetexlive-extra.datapackage) is entirely your app's responsibility, not this library's.serviceUrlalways wins when both are configured — WASM is a fallback, never a preference.engine: 'tectonic'andbibliography: 'biber'aren't supported by this backend (texlyre-busytexdoesn't ship either) and throw aTypeErrorimmediately; usepdflatex/xelatex/lualatexandbibtex/none, or configure aserviceUrlfor full Tectonic/biber support.- A single WASM runner instance is reused across calls in the same session (recreated only if
wasm.basePathchanges), 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
/tmpon 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 deployVercel 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/platex3. 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 = handleCompileRequestIt 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 = handleCompileRequestTanStack 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 upThen 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.
