Guide to Building an Image to PDF Converter with Next.js (Part One)

Peter AbahPeter Abah
11 min read

In this article, we'll build a simple web app that converts images to PDFs. By the end, you'll know how to set up a Next.js project, manage image uploads, and generate PDF files from the images. In Part two, we will implement drag-and-drop reordering and customization of the generated PDFs.

Before you start

This article assumes basic knowledge of React, Next.js and Typescript. We will use TailwindCSS and shadcn components for styling.

Features

Here are the core features of our image to PDF converter:

  • Convert images to PDFs: Users can upload images and convert them to a single PDF file.

  • Customizable PDF options: Control margins, page orientation, and file compression.

  • Drag-and-drop reordering: Users can easily reorder images before generating the PDF.

Getting started

Initialize a new Next.js App:

Open your terminal and enter this command to create a new Next.js app:

npx create-next-app@latest image-to-pdf --ts --tailwind --app --src-dir --eslint --import-alias "@/*"

This creates a new Next.js app in the image-to-pdf directory. The options do the following:

  • --ts: Initialize as a TypeScript project.

  • --tailwind: Initialize with Tailwind CSS config.

  • --app: Initialize as an App Router project.

  • --src-dir: Initialize inside a src/ directory.

  • --eslint: Initialize with ESLint config.

  • --import-alias "@/*": Use @/* as the import alias.

Navigate to the project directory and open it in your code editor (I am using VS Code):

cd image-to-pdf
code .

Init shadcn

We will use shadcn components in this project. Run the following command to initialize shadcn configuration and dependencies for the project:

npx shadcn@latest init -d

The init command installs dependencies, adds the cn util, configures tailwind.config.js, and CSS variables for the project. The -d option uses the default values for the command.

Run the Development Server

Run this command to start the development server:

npm run dev

Visit http://localhost:3000 to view your application. The site should look like this now:

App structure

At this point, it's beneficial to understand the structure of our Next.js app. The following image shows a visual representation of the folder layout:

Converting Images to PDF

Next, we'll implement the image upload functionality and generate a PDF using jsPDF.

Selecting Images

To add a file input for the images, open /src/app/page.tsx and change the contents of the file to this code:

"use client";

import { cn } from "@/lib/utils";
import { ImageUp } from "lucide-react";
import { ChangeEvent, DragEvent, useState } from "react";

interface PDFImage {
  file: File;
  dataURL: string;
  width: number;
  height: number;
}

const ACCEPTED_IMAGE_TYPES = ["jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff"] as const;

const transformImageFile = async (file: File): Promise<PDFImage> => {
  return new Promise((resolve) => {
    const imgElem = document.createElement("img");
    const dataURL = URL.createObjectURL(file);

    imgElem.addEventListener("load", () => {
      resolve({
        file,
        dataURL,
        width: imgElem.naturalWidth,
        height: imgElem.naturalHeight,
      });
    });
    imgElem.src = dataURL;
  });
};

const transformFiles = async (images: File[]) => {
  const res: PDFImage[] = [];
  for (const imgFile of images) {
    res.push(await transformImageFile(imgFile));
  }

  return res;
};

export default function Home() {
  const [images, setImages] = useState<PDFImage[]>([]);
  const [isDragOver, setIsDragOver] = useState(false);

  const handleFileChange = async (e: ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    if (files.length < 1) return;

    setImages(await transformFiles(files));
  };

  const handleDrop = async (e: DragEvent<HTMLLabelElement>) => {
    e.preventDefault();
    setIsDragOver(false);

    const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
      ACCEPTED_IMAGE_TYPES.some((type) => f.type.endsWith(type))
    );

    if (files.length < 1) {
      return;
    }
    setImages(await transformFiles(files));
  };

  const handleDragOver = (e: DragEvent<HTMLLabelElement>) => {
    e.preventDefault();
    setIsDragOver(true);
  };

  const handleDragLeave = (e: DragEvent<HTMLLabelElement>) => {
    e.preventDefault();
    setIsDragOver(false);
  };

  return (
    <main>
      <div className="p-8 flex flex-col justify-center items-center gap-4">
        <div className="max-w-[600px] mx-auto text-center space-y-4">
          <h2 className="font-semibold text-xl">Upload Your Images to Create a PDF</h2>
          <p className="text-lg">
            Drag and drop your images below or click to upload. You can reorder, preview, and edit
            images before generating your PDF.
          </p>
        </div>

        <div>
          <label
            htmlFor="select-image"
            className={cn(
              "cursor-pointer hover:bg-primary-foreground border-2 border-dashed p-6 rounded-xl w-full max-w-[400px] aspect-[2/1] flex flex-col gap-4 items-center justify-center focus-within:border-black",
              { "bg-primary-foreground": isDragOver }
            )}
            onDragOver={handleDragOver}
            onDrop={handleDrop}
            onDragLeave={handleDragLeave}
          >
            <input
              className="sr-only"
              id="select-image"
              type="file"
              onChange={handleFileChange}
              multiple
              accept={ACCEPTED_IMAGE_TYPES.map(s => `.${s}`).join(",")}
            />
            <ImageUp width={50} height={50} />
            <p className="text-center font-medium">
              Drag & drop images here or click to select images.{" "}
              <span className="opacity-80">({ACCEPTED_IMAGE_TYPES.join(", ")})</span>
            </p>
          </label>
        </div>
      </div>
    </main>
  );
}

http:/localhost:3000 should look like this now:

Let's explain what this code does:

  • The "use client" directive is added at the top of the file to indicate the page is a client component (uses React state and event listeners).

  • Next we import the needed functions and components for the page. cn is an utility function that merges css classnames strings together.

  • Then we define the type for the image file, the dataURL will be used to preview the images before PDF generation, and the width and height will be used during PDF generation.

  • The transformImageFile function that converts the uploaded file to an object containing the data URL, image width and height. The image width and height are used to maintain the image aspect ratio in the generated PDF file.

  • The valid image types are stored in a constant for easy manipulation.

  • We declare state to store images and define handlers for file input and drag-and-drop events in the component.

  • Finally we render the page component with the file input

Displaying uploaded images

To display the uploaded images, we will hide the the file input when we have at least one image and display the images in a grid.

Extract images file input component

Let's first extract the file input to a separate component. Create this file /src/components/images-input.tsx and add the following code:

"use client";

import { ACCEPTED_IMAGE_TYPES } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { ImageUp } from "lucide-react";
import { ChangeEvent, DragEvent, useState } from "react";

interface ImagesInputProps {
  onImagesChange: (images: File[]) => void;
}
export default function ImagesInput({ onImagesChange }: ImagesInputProps) {
  const [isDragOver, setIsDragOver] = useState(false);

  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    if (files.length < 1) return;

    onImagesChange(files);
  };

  const handleDrop = (e: DragEvent<HTMLLabelElement>) => {
    e.preventDefault();
    setIsDragOver(false);

    const files = Array.from(e.dataTransfer?.files || []).filter((f) =>
      ACCEPTED_IMAGE_TYPES.some((type) => f.type.endsWith(type))
    );

    if (files.length < 1) {
      return;
    }
    onImagesChange(files);
  };

  const handleDragOver = (e: DragEvent<HTMLLabelElement>) => {
    e.preventDefault();
    setIsDragOver(true);
  };

  const handleDragLeave = (e: DragEvent<HTMLLabelElement>) => {
    e.preventDefault();
    setIsDragOver(false);
  };

  return (
    <div className="p-8 flex flex-col justify-center items-center gap-4">
      <div className="max-w-[600px] mx-auto text-center space-y-4">
        <h2 className="font-semibold text-xl">Upload Your Images to Create a PDF</h2>
        <p className="text-lg">
          Drag and drop your images below or click to upload. You can reorder, preview, and edit
          images before generating your PDF.
        </p>
      </div>

      <div>
        <label
          htmlFor="select-image"
          className={cn(
            "cursor-pointer hover:bg-primary-foreground border-2 border-dashed p-6 rounded-xl w-full max-w-[400px] aspect-[2/1] flex flex-col gap-4 items-center justify-center focus-within:border-black",
            { "bg-primary-foreground": isDragOver }
          )}
          onDragOver={handleDragOver}
          onDrop={handleDrop}
          onDragLeave={handleDragLeave}
        >
          <input
            className="sr-only"
            id="select-image"
            type="file"
            onChange={handleFileChange}
            multiple
            accept={ACCEPTED_IMAGE_TYPES.map(s => `.${s}`).join(",")}
          />
          <ImageUp width={50} height={50} />
          <p className="text-center font-medium">
            Drag & drop images here or click to select images.{" "}
            <span className="opacity-80">({ACCEPTED_IMAGE_TYPES.join(", ")})</span>
          </p>
        </label>
      </div>
    </div>
  );
}

Create a file to store your app types, src/lib/types.ts, and move the PDFImage type there:

export interface PDFImage {
  file: File;
  dataURL: string;
  width: number;
  height: number;
}

Create this file, /src/lib/constants.ts, for the constants we will be using and move the ACCEPTED_IMAGE_TYPES constant definition there:

export const ACCEPTED_IMAGE_TYPES = ["jpg", "jpeg", "png", "webp", "bmp", "gif", "tiff"] as const;

Move the transformFiles functions to the utils file, /src/lib/utils.ts:

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
import { PDFImage } from "./types";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

const transformImageFile = async (file: File): Promise<PDFImage> => {
  return new Promise((resolve) => {
    const imgElem = document.createElement("img");
    const dataURL = URL.createObjectURL(file);

    imgElem.addEventListener("load", () => {
      resolve({
        file,
        dataURL,
        width: imgElem.naturalWidth,
        height: imgElem.naturalHeight,
      });
    });
    imgElem.src = dataURL;
  });
};

export const transformFiles = async (images: File[]) => {
  const res: PDFImage[] = [];
  for (const imgFile of images) {
    res.push(await transformImageFile(imgFile));
  }

  return res;
};

Now to use the ImagesInput component on the page, edit the page to this:

"use client";

import ImagesInput from "@/components/images-input";
import { PDFImage } from "@/lib/types";
import { transformFiles } from "@/lib/utils";
import { useState } from "react";

export default function Home() {
  const [images, setImages] = useState<PDFImage[]>([]);

  const handleImagesChange = async (files: File[]) => {
    setImages(await transformFiles(files));
  };

  return (
    <main>
      <ImagesInput onImagesChange={handleImagesChange} />
    </main>
  );
}

Images list component

Create another component to display the uploaded images in this file /src/components/images-list.tsx with the following content:

import { ACCEPTED_IMAGE_TYPES } from "@/lib/constants";
import { PDFImage } from "@/lib/types";
import { ImagePlus } from "lucide-react";
import Image from "next/image";
import { ChangeEvent } from "react";

interface ImagesListProps {
  images: PDFImage[];
  onAppendImages: (files: File[]) => void;
}
export default function ImagesList({ images, onAppendImages }: ImagesListProps) {
  const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
    const files = Array.from(e.target.files || []);
    if (files.length < 1) return;

    onAppendImages(files);
  };

  return (
    <div className="p-8 justify-center grid grid-cols-[repeat(auto-fit,200px)] gap-6">
      {images.map((image) => (
        <div
          key={image.dataURL}
          className="bg-neutral-50 border rounded-md inline-block p-2 min-w-[200px] relative aspect-square"
        >
          <Image src={image.dataURL} alt="" fill className="object-contain" />
        </div>
      ))}
      <label
        id="add-more"
        className="aspect-square rounded-md bg-neutral-50 hover:bg-neutral-100 border flex flex-col justify-center items-center gap-2 cursor-pointer focus-within:border-neutral-500"
      >
        <ImagePlus size={32} />
        <span className="font-medium text-lg">Add more</span>
        <input
          className="hidden"
          id="add-images"
          type="file"
          onChange={handleFileChange}
          multiple
          accept={ACCEPTED_IMAGE_TYPES.map((s) => `.${s}`).join(",")}
        />
      </label>
    </div>
  );
}

The component renders the images in a grid and also a file input to add more images to the uploaded images.

Now let's use the ImagesList component in our page, we check if there are images and render the ImagesList component, else we render our ImagesInput component.

"use client";

import ImagesInput from "@/components/images-input";
import ImagesList from "@/components/images-list";
import { PDFImage } from "@/lib/types";
import { transformFiles } from "@/lib/utils";
import { useState } from "react";

export default function Home() {
  const [images, setImages] = useState<PDFImage[]>([]);

  const handleImagesChange = async (files: File[]) => {
    setImages(await transformFiles(files));
  };


  const handleAppendImages = async (files: File[]) => {
    const transformed = await transformFiles(files);
    setImages((prev) => [...prev, ...transformed]);
  };

  return (
    <main>
      {images.length > 0 ? (
        <ImagesList images={images} onAppendImages={handleAppendImages} />
      ) : (
        <ImagesInput onImagesChange={handleImagesChange} />
      )}
    </main>
  );
}

Generating PDFs

We will use jsPDF library to generate the PDF file from the images.

First install jsPDF:

npm install jspdf

Then edit the code in your page file, /src/app/page.tsx, to this:

"use client";

import ImagesInput from "@/components/images-input";
import ImagesList from "@/components/images-list";
import { PDFImage } from "@/lib/types";
import { transformFiles } from "@/lib/utils";
import jsPDF, { ImageFormat } from "jspdf";
import { useState } from "react";

const getImageOptions = (image: PDFImage, pageSize: { w: number; h: number }) => {
  const pageAspectRatio = pageSize.w / pageSize.h;
  const imageAspectRatio = image.width / image.height;

  let dimension: { x: number; y: number; width: number; height: number };

  if (imageAspectRatio > pageAspectRatio) {
    const height = pageSize.w / imageAspectRatio;
    dimension = { x: 0, y: (pageSize.h - height) / 2, width: pageSize.w, height: height };
  } else {
    const width = pageSize.h * imageAspectRatio;
    dimension = { x: (pageSize.w - width) / 2, y: 0, width: width, height: pageSize.h };
  }

  return {
    imageData: image.dataURL,
    format: image.file.type.split("/")[1] as ImageFormat,
    ...dimension,
  };
};

const generatePDF = (images: PDFImage[]) => {
  const doc = new jsPDF({ compress: true });
  const pageSize = doc.internal.pageSize;
  images.forEach((image, index) => {
    doc.addImage(getImageOptions(image, { w: pageSize.getWidth(), h: pageSize.getHeight() }));
    if (index < images.length - 1) {
      doc.addPage();
    }
  });
  doc.save("file.pdf");
};

export default function Home() {
  const [images, setImages] = useState<PDFImage[]>([]);

  const handleImagesChange = async (files: File[]) => {
    setImages(await transformFiles(files));
  };

  const handleAppendImages = async (files: File[]) => {
    const transformed = await transformFiles(files);
    setImages((prev) => [...prev, ...transformed]);
  };

  return (
    <main>
      {images.length > 0 ? (
        <div>
          <ImagesList images={images} onAppendImages={handleAppendImages} />
          <button
            type="button"
            onClick={() => generatePDF(images)}
            className="fixed py-4 px-6 bg-primary text-primary-foreground rounded-full shadow bottom-4 right-4 hover:bg-primary/80"
          >
            Generate PDF
          </button>
        </div>
      ) : (
        <ImagesInput onImagesChange={handleImagesChange} />
      )}
    </main>
  );
}

The generatePDF function creates a PDF file with all the images added using jspdf and automatically starts downloading the PDF file. getImageOptions function is used to calculate the dimensions and size of the each image before it is added to the PDF file. Once the "Generate PDF" button is clicked, the PDF is generated and it starts downloading.

Now your app should like this:

Improve UI

The UI looks too plain, let's add a header. Create a file to for the header component, /src/components/header.tsx, and add this code:

function Header() {
  return (
    <header className="px-8 h-14 flex items-center shrink-0 border-b">
      <h1 className="font-bold">image2PDF</h1>
    </header>
  );
}

export default Header;

Then edit /src/app/layout.tsx to use the Header component and change the site metadata:

import type { Metadata } from "next";
import localFont from "next/font/local";
import "./globals.css";
import Header from "@/components/header";

const geistSans = localFont({
  src: "./fonts/GeistVF.woff",
  variable: "--font-geist-sans",
  weight: "100 900",
});
const geistMono = localFont({
  src: "./fonts/GeistMonoVF.woff",
  variable: "--font-geist-mono",
  weight: "100 900",
});

export const metadata: Metadata = {
  title: "image2PDF",
  description: "Convert images to PDF files",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        <Header />
        {children}
      </body>
    </html>
  );
}

Our app looks like this now:

Conclusion

We have successfully built an image to PDF converter. You can view the live demo. The code is available in this github repo.

In Part Two, we will implement drag-and-drop reordering and PDF customization.

0
Subscribe to my newsletter

Read articles from Peter Abah directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Peter Abah
Peter Abah