Generating a Printable Monthly Report in Next 13 – From UI to PDF with a Tiny Print Component
TL;DR: I added a client‑side PrintButton and a dedicated report page that renders a clean, printable view of the monthly committee data. The change lives in app/panel/reporte/, updates global print CSS, and isolates the print logic so the rest of the app stays untouched.
The Problem
The Albaida‑Tickets admin panel needed a way for staff to hand‑off a monthly PDF report to the Comité. The existing UI was built with Tailwind‑styled tables and KPI cards, but when users hit Ctrl+P the output was a mess:
- Table rows broke across pages.
- KPI cards overlapped the footer.
- No obvious “Print / Save as PDF” button existed, so staff resorted to screen‑captures.
The symptom showed up in the browser console as a layout overflow warning and in the printed preview as clipped content:
[Violation] Forced reflow while executing JavaScript
Printed page shows: "…/panel/reporte" with broken tables
The requirement was clear: a single click that opens the print dialog and a print‑friendly stylesheet that respects page breaks, margins, and hides interactive elements.
What I Tried First
My first attempt was to sprinkle a few @media print rules directly into app/globals.css and add an inline <button onClick={() => window.print()}>Print</button> inside app/panel/reporte/page.tsx.
// page.tsx (initial attempt)
<button onClick={() => window.print()}>Print</button>
Problems:
- The button rendered inside the printed page, cluttering the PDF.
- The CSS rules were scattered, making it hard to maintain.
- Because the page is a Server Component (default in Next 13), the
windowobject threw a ReferenceError during SSR, breaking the build.
The approach failed fast, so I pivoted to a more modular solution.
The Implementation
1. Isolate the print logic in a client component
Next 13 distinguishes Server and Client components. I created a tiny client‑side component that only calls window.print() and renders a plain button.
File: app/panel/reporte/PrintButton.tsx
// app/panel/reporte/PrintButton.tsx
"use client";
export function PrintButton() {
return (
<button
type="button"
className="btn"
onClick={() => window.print()}
>
Imprimir / Guardar PDF
</button>
);
}
- The
"use client"directive tells Next to bundle this file for the browser only, avoiding SSR errors. - The component is intentionally tiny—no dependencies, no state—so it can be dropped anywhere.
2. Wire the button into the report page
The report page is a Server Component that fetches data with sql and renders the KPI cards and ticket tables. I imported the new PrintButton and placed it at the top of the page.
File: app/panel/reporte/page.tsx (excerpt)
import Link from "next/link";
import { sql, type Ticket } from "@/lib/db";
import { requireStaff } from "@/lib/session";
import { PrintButton } from "./PrintButton";
import { listAgreements, progress } from "@/lib/agreement";
export default async function MonthlyReport() {
await requireStaff(); // auth guard
const tickets = await sql<Ticket>`SELECT * FROM tickets WHERE ...`;
const agreements = await listAgreements();
return (
<section className="report-container">
<header className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Reporte Mensual – Comité</h1>
<PrintButton />
</header>
{/* KPI cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 my-4">
{/* …render KPIs… */}
</div>
{/* Ticket table */}
<table className="report-table">
{/* …render rows… */}
</table>
</section>
);
}
Because the page remains a Server Component, data fetching stays on the server, while the button runs purely on the client.
3. Add print‑specific CSS
I extended app/globals.css with a print media query that:
- Hides navigation, footers, and the print button itself.
- Forces page breaks after each KPI row and before the ticket table.
- Adjusts font sizes for better PDF density.
File: app/globals.css (excerpt)
/* Global print styles */
@media print {
/* Hide interactive UI */
.nav, .footer, .btn {
display: none !important;
}
/* Ensure each KPI card starts on a new page if needed */
.report-container .kpi {
page-break-inside: avoid;
break-inside: avoid;
}
/* Table handling */
.report-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.report-table th,
.report-table td {
padding: 0.4rem 0.6rem;
border: 1px solid #ddd;
}
/* Force a page break before the ticket list */
.report-table {
break-before: page;
}
}
The !important flag guarantees that the button never appears in the printed output, even if a component later adds a conflicting rule.
4. Minor layout tweaks
During testing, the KPI cards overflowed the page margin when printed. I added a small padding fix in the same CSS file:
@@ -575,3 +575,12 @@ details > summary.btn::-webkit-details-marker { display: none; }
.conv-progress .pct { font-size: 0.8rem; font-weight: 700; font-variant-numeric: tabular-nums; }
.conv-progress
+/* Print-friendly spacing */
+@media print {
+ .conv-progress {
+ margin-bottom: 1rem;
+ }
+}
This ensures a clean vertical rhythm on the PDF.
5. Update navigation to include the new route
app/panel/layout.tsx needed a link to the report page. I added a single line:
@@ -22,6 +22,7 @@ export default async function PanelLayout({ children }: { children: React.ReactN
</NavLink>
<NavLink href="/panel/convenios">Convenios</NavLink>
+ <NavLink href="/panel/reporte">Reporte Mensual</NavLink>
No further changes were required because the layout already wraps all panel routes.
Key Takeaway
Separate client‑only behavior into its own component ("use client"), and keep all print‑specific styles in a dedicated @media print block. This prevents SSR errors, keeps the codebase clean, and makes the printable view reusable across any page that needs a “Print / Save PDF” button.
What's Next
-
Server‑side PDF generation – replace the manual
window.print()with a headless Chromium (Puppeteer) service that streams a PDF to the user and optionally emails it to the Comité. - Dynamic page breaks
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/albaida-tickets · 2026-09-30
#playadev #buildinpublic
Top comments (0)