PDFPipe

Guide

HTML to PDF in Strapi

Strapi is the most popular open-source headless CMS built with Node.js. Its plugin architecture lets you extend any content type with custom routes and controllers, and its lifecycle hooks fire automatically on every create or update. That gives you two clean integration points: a custom GET /api/articles/:id/pdf route for on-demand exports, and an afterCreate or afterUpdate lifecycle hook to generate PDFs automatically when content is published.

Overview

Both approaches follow the same pattern: fetch the content entry from Strapi's service layer, build an HTML string from its fields, and POST that HTML to the PDF API. The API returns either a byte stream or a stable download URL depending on whether you pass store: true. Custom route files live under src/api/<content-type>/routes/ and their controllers under src/api/<content-type>/controllers/.

typescript
// Strapi v5 is a Node.js headless CMS.
// Custom routes live under src/api/<content-type>/routes/
// and their controllers under src/api/<content-type>/controllers/.
//
// Install native fetch is available in Node 18+; no extra package needed.
// If you are on an older Node version:
// npm install node-fetch
//
// Set your API key in .env:
// PDFPIPE_API_KEY=your_api_key_here

Register a custom route

Add a new route file alongside your content type's existing routes. Strapi v5 loads all files in the routes/ directory automatically. The route below maps GET /api/articles/:id/pdf to an exportPdf method on the article controller:

typescript
// src/api/article/routes/pdf.ts
// Register a custom GET route under /api/articles/:id/pdf

export default {
  routes: [
    {
      method: "GET",
      path: "/articles/:id/pdf",
      handler: "article.exportPdf",
      config: {
        // Restrict to authenticated users if needed.
        // policies: ["global::is-authenticated"],
      },
    },
  ],
};

Controller: stream the PDF

The controller fetches the entry via strapi.service(...).findOne, builds an HTML template string from its fields, and forwards it to the PDF API. Setting the response headers to Content-Type: application/pdf and Content-Disposition: attachment causes the browser to download the file immediately when the endpoint is opened directly:

typescript
// src/api/article/controllers/article.ts
import { factories } from "@strapi/strapi";

export default factories.createCoreController(
  "api::article.article",
  ({ strapi }) => ({
    // Inherit default CRUD methods, then add exportPdf.
    async exportPdf(ctx) {
      const { id } = ctx.params as { id: string };

      // Fetch the content entry from Strapi's service layer.
      const article = await strapi
        .service("api::article.article")
        .findOne(id, { populate: "*" });

      if (!article) {
        return ctx.notFound("Article not found");
      }

      // Build an HTML string from the entry's fields.
      const html = `<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <style>
      body { font-family: Georgia, serif; max-width: 720px; margin: 40px auto; color: #1d1812; }
      h1   { font-size: 2rem; margin-bottom: 0.5rem; }
      .meta { color: #6b6560; font-size: 0.875rem; margin-bottom: 2rem; }
    </style>
  </head>
  <body>
    <h1>${article.title}</h1>
    <p class="meta">Published ${new Date(article.publishedAt).toDateString()}</p>
    ${article.contentHtml ?? ""}
  </body>
</html>`;

      const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          html,
          options: { format: "A4" },
        }),
      });

      if (!upstream.ok) {
        const err = await upstream.json().catch(() => ({}));
        ctx.status = 502;
        ctx.body = { error: (err as any).detail ?? "render failed" };
        return;
      }

      // Stream the PDF bytes back as a download.
      const pdfBuffer = await upstream.arrayBuffer();
      ctx.set("Content-Type", "application/pdf");
      ctx.set(
        "Content-Disposition",
        `attachment; filename="${article.slug ?? id}.pdf"`
      );
      ctx.body = Buffer.from(pdfBuffer);
    },
  })
);

Variant: return a document URL

Pass store: true to the API and it stores the rendered PDF and returns a stable download URL in the response body. This is more useful than streaming bytes when you want to email the PDF, display a download link on the dashboard, or save the URL back to the content entry for later retrieval:

typescript
// src/api/article/controllers/article.ts
// Variant: pass store: true to get a URL instead of a byte stream.
// Useful for emailing the PDF or linking from the dashboard.

async exportPdfUrl(ctx) {
  const { id } = ctx.params as { id: string };

  const article = await strapi
    .service("api::article.article")
    .findOne(id, { populate: "*" });

  if (!article) {
    return ctx.notFound("Article not found");
  }

  const html = `<!DOCTYPE html>
<html>
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>${article.title}</h1>
    ${article.contentHtml ?? ""}
  </body>
</html>`;

  const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: { format: "A4" },
      store: true,
      filename: `${article.slug ?? id}.pdf`,
    }),
  });

  if (!upstream.ok) {
    const err = await upstream.json().catch(() => ({}));
    ctx.status = 502;
    ctx.body = { error: (err as any).detail ?? "render failed" };
    return;
  }

  // store: true returns a stable download URL in the response body.
  const result = await upstream.json();
  ctx.body = { document_url: result.document_url };
}

Lifecycle hooks

For fully automatic PDF generation, use Strapi's lifecycle hooks. Place a lifecycles.ts file under src/api/article/content-types/article/. The afterCreate and afterUpdate hooks fire after the database write completes. The example below checks for a publishedAt timestamp before generating the PDF, so drafts are skipped. The resulting URL is written back to the entry via strapi.entityService.update:

typescript
// src/api/article/content-types/article/lifecycles.ts
// Auto-generate a PDF and store the URL whenever an article is published.

export default {
  async afterCreate(event: any) {
    await generateAndStorePdf(event.result);
  },

  async afterUpdate(event: any) {
    // Only act when the article transitions to published.
    if (event.result.publishedAt) {
      await generateAndStorePdf(event.result);
    }
  },
};

async function generateAndStorePdf(entry: any) {
  if (!entry.publishedAt) return;

  const html = `<!DOCTYPE html>
<html>
  <head><meta charset="utf-8" /></head>
  <body>
    <h1>${entry.title}</h1>
    ${entry.contentHtml ?? ""}
  </body>
</html>`;

  const upstream = await fetch("https://api.pdfpipe.xyz/v1/pdf", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.PDFPIPE_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      html,
      options: { format: "A4" },
      store: true,
      filename: `${entry.slug ?? entry.id}.pdf`,
    }),
  });

  if (!upstream.ok) return;

  const { document_url } = await upstream.json();

  // Write the PDF URL back to the entry using Strapi's entity service.
  await strapi.entityService.update("api::article.article", entry.id, {
    data: { pdfUrl: document_url },
  });
}

Environment variable

Store your API key in a .env file at the project root. Strapi loads it automatically in development. In production, set it through your hosting environment so it is available as process.env.PDFPIPE_API_KEY at runtime:

sh
# .env  (project root, alongside strapi.config.ts)
PDFPIPE_API_KEY=your_api_key_here

# Strapi reads .env automatically in development via dotenv.
# In production, inject this variable through your host's secret manager
# (Railway variables, Render environment, etc.): never commit the value.

Getting an API key

The Hobby plan gives 500 free documents a month with no credit card required. Paid plans start at $19 and include a longer document archive, higher limits, and email support.

Full API reference on the docs page. Try it now in the live playground with no signup.

Get an API key →