DEV Community

Roberto Luna
Roberto Luna

Posted on

Generating a Printable Monthly Report in Next 13 – From UI to PDF with a Tiny Print Component

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Problems:

  1. The button rendered inside the printed page, cluttering the PDF.
  2. The CSS rules were scattered, making it hard to maintain.
  3. Because the page is a Server Component (default in Next 13), the window object 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
  • 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
+  }
+}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)