Backend examples
Serve report definitions and data as JSON from Node.js, Java Spring Boot, C# ASP.NET Core, Python, PHP or Go, and record the output log; any backend works.
FreeReport lays out, prints and saves files in the browser, so the server only answers with JSON: the report form and the rows. Any language and framework works, and nothing has to be installed on the server. The examples below implement the same four endpoints, so the browser code stays the same whichever backend you pick.
| Endpoint | Returns | Description |
|---|---|---|
GET /api/reports/{name} | JSON | The report form (definition) saved from the designer. |
PUT /api/reports/{name} | 204 | Save the form (designer onSave). Check the name and that the body is JSON. |
GET /api/reports/sales/data?from=&to= | { rows: [...] } | Rows for the parameters (viewer fetchData). Keys are the field names used in the form. |
POST /api/report-logs | 204 | One print or download (viewer onOutput): kind, report, pages, rows, params. |
Browser (the same for every backend)
import { createViewer } from '@freereportjs/core';
import '@freereportjs/core/styles.css';
const json = (url: string, init?: RequestInit) =>
fetch(url, init).then(r => { if (!r.ok) throw new Error(r.status + ' ' + url); return r.json(); });
// 1. the report form (made in the designer) 2. rows for the parameters 3. output log
const viewer = createViewer(document.getElementById('viewer')!, {
definition: await json('/api/reports/sales'),
params: { from: '2026-10-01', to: '2026-10-31' },
fetchData: params => json('/api/reports/sales/data?' + new URLSearchParams(params as Record<string, string>)),
onOutput: e => fetch('/api/report-logs', {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(e),
}).then(r => r.ok), // false: the server refused, nothing is printed or saved
lang: 'ko',
});
// designer: createDesigner(el, { onSave: def => fetch('/api/reports/sales', { method: 'PUT', body: JSON.stringify(def), headers: { 'content-type': 'application/json' } }) })Server examples
// Node.js 20+ · npm i express@5 pg @freereportjs/core
import express from 'express';
import { readFile, writeFile } from 'node:fs/promises';
import pg from 'pg';
import { loadFonts, renderPdf } from '@freereportjs/core';
const app = express();
const db = new pg.Pool(); // PGHOST, PGUSER, … from the environment
app.use(express.json({ limit: '5mb' }));
const file = name => {
if (!/^[\w-]+$/.test(name)) throw Object.assign(new Error('bad name'), { status: 400 });
return 'reports/' + name + '.json';
};
const salesRows = async (from, to) => (await db.query(
`SELECT to_char(sold_on, 'YYYY-MM-DD') AS date, region, product, qty, amount::float8 AS amount
FROM sales WHERE sold_on BETWEEN $1 AND $2 ORDER BY region, sold_on`, [from, to])).rows;
// report form: the viewer loads it, the designer saves it
app.get('/api/reports/:name', async (req, res) => { res.type('json').send(await readFile(file(req.params.name), 'utf8')); });
app.put('/api/reports/:name', async (req, res) => {
await writeFile(file(req.params.name), JSON.stringify(req.body, null, 2));
res.sendStatus(204);
});
// data for the viewer's parameters
app.get('/api/reports/sales/data', async (req, res) => {
res.json({ rows: await salesRows(String(req.query.from), String(req.query.to)) });
});
// output log (viewer onOutput)
app.post('/api/report-logs', async (req, res) => {
const { kind, report, pages, rows, params } = req.body;
await db.query('INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[req.user?.id ?? null /* from your auth middleware */, kind, report, pages, rows, JSON.stringify(params), req.ip]);
res.sendStatus(204);
});
// only JavaScript runtimes can also make the PDF on the server (batch jobs, e-mail attachments)
const fonts = await loadFonts(['Nanum Gothic']);
app.get('/api/reports/sales/pdf', async (req, res) => {
const definition = JSON.parse(await readFile(file('sales'), 'utf8'));
const rows = await salesRows(String(req.query.from), String(req.query.to));
res.type('pdf').send(Buffer.from(await renderPdf(definition, { rows }, { fonts, params: req.query })));
});
app.listen(3000);// Spring Boot 3 (spring-boot-starter-web, spring-boot-starter-jdbc) · Java 17+
@RestController
@RequestMapping("/api")
public class ReportController {
private static final Path DIR = Path.of("reports");
private final JdbcTemplate jdbc;
private final ObjectMapper json;
public ReportController(JdbcTemplate jdbc, ObjectMapper json) { this.jdbc = jdbc; this.json = json; }
private static Path file(String name) {
if (!name.matches("[\\w-]+")) throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
return DIR.resolve(name + ".json");
}
// report form: the viewer loads it, the designer saves it
@GetMapping(value = "/reports/{name}", produces = MediaType.APPLICATION_JSON_VALUE)
public String definition(@PathVariable String name) throws IOException {
return Files.readString(file(name));
}
@PutMapping(value = "/reports/{name}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> save(@PathVariable String name, @RequestBody String body) throws IOException {
json.readTree(body); // reject anything that is not JSON
Files.writeString(file(name), body);
return ResponseEntity.noContent().build();
}
// data for the viewer's parameters (quoted aliases keep lower-case keys on every database)
@GetMapping("/reports/sales/data")
public Map<String, Object> sales(@RequestParam LocalDate from, @RequestParam LocalDate to) {
return Map.of("rows", jdbc.queryForList("""
SELECT sold_on AS "date", region AS "region", product AS "product", qty AS "qty", amount AS "amount"
FROM sales WHERE sold_on BETWEEN ? AND ? ORDER BY region, sold_on""", from, to));
}
// output log (viewer onOutput)
@PostMapping("/report-logs")
public ResponseEntity<Void> log(@RequestBody Map<String, Object> e, Principal user, HttpServletRequest req) throws JsonProcessingException {
jdbc.update("INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES (?, ?, ?, ?, ?, ?, ?)",
user == null ? null : user.getName(), e.get("kind"), e.get("report"), e.get("pages"), e.get("rows"),
json.writeValueAsString(e.get("params")), req.getRemoteAddr());
return ResponseEntity.noContent().build();
}
}// ASP.NET Core 8+ minimal API · dotnet add package Dapper Microsoft.Data.SqlClient
using System.Text.Json;
using System.Text.RegularExpressions;
using Dapper;
using Microsoft.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var cs = builder.Configuration.GetConnectionString("Default");
string ReportFile(string name) => Regex.IsMatch(name, @"^[\w-]+$")
? Path.Combine("reports", name + ".json")
: throw new BadHttpRequestException("bad name");
// report form: the viewer loads it, the designer saves it
app.MapGet("/api/reports/{name}", (string name) =>
Results.Text(File.ReadAllText(ReportFile(name)), "application/json"));
app.MapPut("/api/reports/{name}", async (string name, HttpRequest req) =>
{
using var doc = await JsonDocument.ParseAsync(req.Body); // reject anything that is not JSON
await File.WriteAllTextAsync(ReportFile(name), doc.RootElement.GetRawText());
return Results.NoContent();
});
// data for the viewer's parameters
app.MapGet("/api/reports/sales/data", async (DateOnly from, DateOnly to) =>
{
await using var db = new SqlConnection(cs);
var rows = await db.QueryAsync(
"""
SELECT CONVERT(char(10), sold_on, 23) AS [date], region, product, qty, amount
FROM sales WHERE sold_on BETWEEN @from AND @to ORDER BY region, sold_on
""",
new { from = from.ToDateTime(TimeOnly.MinValue), to = to.ToDateTime(TimeOnly.MinValue) });
return Results.Ok(new { rows });
});
// output log (viewer onOutput)
app.MapPost("/api/report-logs", async (JsonElement e, HttpContext ctx) =>
{
await using var db = new SqlConnection(cs);
await db.ExecuteAsync(
"INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES (@user, @kind, @report, @pages, @rows, @params, @ip)",
new
{
user = ctx.User.Identity?.Name,
kind = e.GetProperty("kind").GetString(),
report = e.GetProperty("report").GetString(),
pages = e.GetProperty("pages").GetInt32(),
rows = e.GetProperty("rows").GetInt32(),
@params = e.GetProperty("params").GetRawText(),
ip = ctx.Connection.RemoteIpAddress?.ToString(),
});
return Results.NoContent();
});
app.Run();# Python 3.10+ · pip install fastapi uvicorn "psycopg[binary]"
import json, re
from datetime import date
from pathlib import Path
from fastapi import FastAPI, HTTPException, Query, Request, Response
import psycopg
from psycopg.rows import dict_row
app = FastAPI()
DIR = Path("reports")
DSN = "dbname=erp user=report"
def report_file(name: str) -> Path:
if not re.fullmatch(r"[\w-]+", name):
raise HTTPException(400, "bad name")
return DIR / f"{name}.json"
# report form: the viewer loads it, the designer saves it
@app.get("/api/reports/{name}")
def definition(name: str):
return Response(report_file(name).read_text("utf-8"), media_type="application/json")
@app.put("/api/reports/{name}", status_code=204)
async def save(name: str, request: Request):
body = await request.json() # rejects anything that is not JSON
report_file(name).write_text(json.dumps(body, ensure_ascii=False, indent=2), "utf-8")
# data for the viewer's parameters (dates become "2026-10-01", numeric becomes a number)
@app.get("/api/reports/sales/data")
def sales(from_: date = Query(alias="from"), to: date = Query()):
with psycopg.connect(DSN, row_factory=dict_row) as db:
rows = db.execute(
"SELECT sold_on AS date, region, product, qty, amount FROM sales"
" WHERE sold_on BETWEEN %s AND %s ORDER BY region, sold_on", (from_, to)).fetchall()
return {"rows": rows}
# output log (viewer onOutput)
@app.post("/api/report-logs", status_code=204)
async def log(request: Request):
e = await request.json()
with psycopg.connect(DSN) as db:
db.execute(
"INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip)"
" VALUES (%s, %s, %s, %s, %s, %s, %s)",
(None, # your signed-in user
e["kind"], e["report"], e["pages"], e["rows"],
json.dumps(e["params"], ensure_ascii=False), request.client.host))
# run: uvicorn main:app<?php
// PHP 8+ · send /api/* to this file
// Apache (.htaccess in /api): FallbackResource /api/index.php
// nginx: location /api/ { try_files $uri /api/index.php?$query_string; }
$pdo = new PDO('mysql:host=localhost;dbname=erp;charset=utf8mb4', 'report', getenv('DB_PASSWORD'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$path = trim(substr(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), strlen('/api/')), '/');
$method = $_SERVER['REQUEST_METHOD'];
header('Content-Type: application/json; charset=utf-8');
// report form: the viewer loads it, the designer saves it
if (preg_match('#^reports/([\w-]+)$#', $path, $m)) {
$file = __DIR__ . '/reports/' . $m[1] . '.json';
if ($method === 'GET') { readfile($file); exit; }
if ($method === 'PUT') {
$body = file_get_contents('php://input');
json_decode($body, flags: JSON_THROW_ON_ERROR); // reject anything that is not JSON
file_put_contents($file, $body);
http_response_code(204); exit;
}
}
// data for the viewer's parameters (JSON_NUMERIC_CHECK: MySQL decimals arrive as strings)
if ($path === 'reports/sales/data' && $method === 'GET') {
$q = $pdo->prepare('SELECT sold_on AS date, region, product, qty, amount FROM sales
WHERE sold_on BETWEEN ? AND ? ORDER BY region, sold_on');
$q->execute([$_GET['from'], $_GET['to']]);
echo json_encode(['rows' => $q->fetchAll()], JSON_UNESCAPED_UNICODE | JSON_NUMERIC_CHECK); exit;
}
// output log (viewer onOutput)
if ($path === 'report-logs' && $method === 'POST') {
$e = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
$pdo->prepare('INSERT INTO report_log (user_id, kind, report, pages, row_count, params, ip) VALUES (?, ?, ?, ?, ?, ?, ?)')
->execute([$_SESSION['user_id'] ?? null, $e['kind'], $e['report'], $e['pages'], $e['rows'],
json_encode($e['params'], JSON_UNESCAPED_UNICODE), $_SERVER['REMOTE_ADDR']]);
http_response_code(204); exit;
}
http_response_code(404);// Go 1.22+ (method and {name} patterns in net/http) · go get github.com/jackc/pgx/v5
package main
import (
"database/sql"
"encoding/json"
"io"
"net/http"
"os"
"regexp"
_ "github.com/jackc/pgx/v5/stdlib"
)
var validName = regexp.MustCompile(`^[\w-]+$`)
type sale struct {
Date string `json:"date"`
Region string `json:"region"`
Product string `json:"product"`
Qty int `json:"qty"`
Amount float64 `json:"amount"`
}
func main() {
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
// report form: the viewer loads it, the designer saves it
http.HandleFunc("GET /api/reports/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
if !validName.MatchString(name) {
http.Error(w, "bad name", http.StatusBadRequest)
return
}
http.ServeFile(w, r, "reports/"+name+".json")
})
http.HandleFunc("PUT /api/reports/{name}", func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
body, err := io.ReadAll(io.LimitReader(r.Body, 5<<20))
if !validName.MatchString(name) || err != nil || !json.Valid(body) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if err := os.WriteFile("reports/"+name+".json", body, 0o644); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
// data for the viewer's parameters
http.HandleFunc("GET /api/reports/sales/data", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
rows, err := db.QueryContext(r.Context(),
`SELECT to_char(sold_on, 'YYYY-MM-DD'), region, product, qty, amount::float8
FROM sales WHERE sold_on BETWEEN $1 AND $2 ORDER BY region, sold_on`, q.Get("from"), q.Get("to"))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
out := []sale{}
for rows.Next() {
var s sale
if err := rows.Scan(&s.Date, &s.Region, &s.Product, &s.Qty, &s.Amount); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
out = append(out, s)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"rows": out})
})
// output log (viewer onOutput)
http.HandleFunc("POST /api/report-logs", func(w http.ResponseWriter, r *http.Request) {
var e struct {
Kind string `json:"kind"`
Report string `json:"report"`
Pages int `json:"pages"`
Rows int `json:"rows"`
Params json.RawMessage `json:"params"`
}
if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
_, err := db.ExecContext(r.Context(),
`INSERT INTO report_log (kind, report, pages, row_count, params, ip) VALUES ($1, $2, $3, $4, $5, $6)`,
e.Kind, e.Report, e.Pages, e.Rows, string(e.Params), r.RemoteAddr)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
})
http.ListenAndServe(":8080", nil)
}-- output log table (PostgreSQL; MySQL: BIGINT AUTO_INCREMENT, SQL Server: BIGINT IDENTITY, DATETIME2)
CREATE TABLE report_log (
id BIGSERIAL PRIMARY KEY,
logged_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
user_id VARCHAR(64),
kind VARCHAR(16) NOT NULL, -- print, pdf, xlsx, xlsx-data, csv, hwpx
report VARCHAR(100) NOT NULL,
pages INT,
row_count INT,
params TEXT, -- the parameters as JSON
ip VARCHAR(45)
);
CREATE INDEX report_log_time ON report_log (logged_at);Points to check
- Return dates as "yyyy-MM-dd" strings and amounts as numbers, so formats and sums work. The keys of each row are the field names in the form.
- Check the report name before using it in a file path, and check that a saved form is JSON. Put the save and log endpoints behind your login.
- Keep forms in a database table instead of files if you run several servers: the definition is one JSON text column.
- PDF or Excel files made on the server need a JavaScript runtime (renderPdf and renderXlsx in Node.js, Deno or Bun). From Java, C#, Python, PHP or Go, call a small Node service, or let the browser make the file. Server rendering
- Spring with JSP (eGovFrame): see the downloadable sample project. Frameworks and eGovFrame