Guide to Building an Image to PDF Converter with Next.js (Part One)
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 asrc/
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.
Subscribe to my newsletter
Read articles from Peter Abah directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by