Generate PDFs in FastAPI

Add a PDF download to an existing FastAPI page, or generate a PDF from a template. This guide shows how to render Jinja templates in FastAPI route handlers and return PDF downloads using PDFCrowd's HTML to PDF API.

Set up the client

Install the Python client:

python -m pip install pdfcrowd

You can run the examples below with the included demo credentials, or replace them with your PDFCrowd username and API key.

Add a PDF download to an existing page

An existing route can display its usual HTML page and return a PDF when the user clicks a download button. Reuse the page's template, data preparation, and access checks.

Choose what to send to PDFCrowd:

Convert rendered HTML

Here, app is your existing FastAPI application, and templates is its Jinja2Templates instance configured with directory="templates". The example extends a document route; get_document_for_current_user(document_id) represents your application's data lookup and permission checks. Substitute your own logic here. The template at templates/documents/detail.html receives the result as document.

Jinja rendering needs jinja2, and FastAPI's Form parameter needs python-multipart. Install them if your application does not already use them:

python -m pip install jinja2 python-multipart

Use a normal def route handler. FastAPI runs it in a worker thread, so the synchronous PDFCrowd call does not block the event loop.

from typing import Annotated

import pdfcrowd
from fastapi import Form, Request
from fastapi.responses import Response


@app.api_route("/documents/{document_id}", methods=["GET", "POST"])
def document_detail(
    document_id: int,
    request: Request,
    download_pdf: Annotated[str | None, Form()] = None,
):
    document = get_document_for_current_user(document_id)

    if request.method == "POST" and download_pdf is not None:
        page = templates.TemplateResponse(
            request=request,
            name="documents/detail.html",
            context={
                "document": document,
                "pdf_base_url": getattr(app.state, "pdf_base_url", None),
            },
        )
        html = page.body.decode("utf-8")

        try:
            client = pdfcrowd.HtmlToPdfClient("demo", "demo")  # Username, API key
            client.setContentViewportWidth("balanced")
            pdf = client.convertString(html)  # bytes
        except pdfcrowd.Error:
            # Add your application's error handling here.
            raise

        return Response(
            pdf,
            media_type="application/pdf",
            headers={
                "Content-Disposition": (
                    f'attachment; filename="document-{document_id}.pdf"'
                ),
            },
        )

    return templates.TemplateResponse(
        request=request,
        name="documents/detail.html",
        context={"document": document},
    )

Add this form to the template, outside any existing form:

<form method="post" class="pdfcrowd-remove">
    <button type="submit" name="download_pdf" value="1">Download PDF</button>
</form>

Include your application's CSRF field and validation; they are omitted from this fragment. FastAPI leaves CSRF protection to the application or its middleware. Keep that validation and your existing authentication dependencies in place before conversion.

GET requests display the HTML page. A POST containing download_pdf renders the same template, converts it, and returns a PDF download. TemplateResponse renders Jinja immediately, so its body supplies the HTML to convert, including any template context processors. The pdfcrowd-remove class hides the form in the PDF while leaving it visible on the webpage.

Conversion finishes before the PDF response is created, with the PDF held in memory as bytes. The except block provides a place for your application's error handling. As written, raise propagates the original exception. See SDK error handling for details.

To display the PDF in the browser, change attachment to inline. Set page size, margins, or other PDF options before convertString().

Make CSS and images available

PDFCrowd needs reachable URLs for stylesheets, images, and fonts referenced by the rendered HTML. Absolute asset URLs, such as https://your-site.example/static/document.css, need no additional base URL.

For relative paths, set the public site or directory URL during application setup:

app.state.pdf_base_url = "https://your-site.example/"

Replace https://your-site.example/ with your website's public URL. Then add this to the template's <head>, before stylesheet links:

{% if pdf_base_url %}
<base href="{{ pdf_base_url }}">
{% endif %}

The PDF branch passes this setting as pdf_base_url. The <base> element tells PDFCrowd where to resolve relative URLs. For example, /static/document.css becomes https://your-site.example/static/document.css. This applies to assets and hyperlinks; avoid adding a second <base> if the template already supplies one.

PDFCrowd cannot reach assets on your development machine through localhost or 127.0.0.1. For local assets, you can instead package the HTML and assets in a ZIP archive. Keep paths relative to the archive's folders and omit the website <base> for that approach.

Convert the page URL instead

Instead of sending rendered HTML, you can ask PDFCrowd to open the page through its URL. Keep the lookup, POST check, client setup, error handling, and response code above. Replace the page and html assignments inside the PDF branch and its convertString() call with:

pdf = client.convertUrl(str(request.url))  # bytes

This preserves the current path and query parameters. PDFCrowd makes a separate GET request, so the route returns HTML; only the original POST triggers conversion. The server must be able to handle that GET while the POST waits for the PDF.

The URL must be reachable from PDFCrowd; localhost cannot reach your development machine. Ensure request.url uses your public address, with the appropriate trusted-host and proxy settings.

The separate request does not inherit the user's session or authentication headers. For protected pages, configure cookies or HTTP authentication as appropriate, or use rendered HTML from the original request.

Generate a PDF from a template

You can also generate a PDF directly from a template and application data. Use an existing Jinja template or a separate template designed for the document.

This function uses the same templates instance and asset configuration as the route example. It renders the supplied template and returns the PDF as bytes:

import pdfcrowd


def render_pdf(template_name, context):
    pdf_context = {
        **context,
        "pdf_base_url": getattr(app.state, "pdf_base_url", None),
    }
    html = templates.get_template(template_name).render(pdf_context)
    client = pdfcrowd.HtmlToPdfClient("demo", "demo")  # Username, API key
    client.setContentViewportWidth("balanced")
    return client.convertString(html)

For example, given a document object, call it from a normal def route handler:

pdf = render_pdf(
    "documents/detail.html", {"document": document, "request": request},
)  # bytes

Pass all data the template needs in context, including request when it uses url_for or other request-dependent values. Direct Jinja rendering does not run TemplateResponse context processors; supply any values they normally add. Use the resulting bytes for a download, storage, or email. Conversion errors propagate to the caller.

Outside a request, use a template that does not depend on request or session data. The helper is synchronous: from an async def handler, offload it with await run_in_threadpool(render_pdf, template_name, context), importing run_in_threadpool from starlette.concurrency.

For more conversion inputs and PDF settings, see the Python examples and API reference.