Everyone uses PDFs — it goes without saying that it’s one of the most common document formats. Therefore, a feature of many apps is to create PDFs from other kinds of documents. Creating PDFs is easy in Node.js by using third-party libraries, and we can easily add the feature to our own apps.
In this article, we will build an app that lets users enter their document in a rich text editor and generate a PDF from it. We will use Express for the backend and React for the frontend.
Backend
We will start with the backend. To start, we will create a project directory with a backend
folder inside. Then in the backend
folder, run npx express-generator
to create the Express app. Then run npm i
to install the packages.
Next, we install our own packages. We need Babel to run the app with the latest version of JavaScript, CORS for cross-domain requests from the frontend, HTML-PDF for converting HTML strings to PDF, Multer for file upload, Sequelize for the ORM, and SQLite3 for our database.
We install these by running npm i @babel/cli @babel/core @babel/node @babel/preset-env cors html-pdf sequelize sqlite3 multer
.
After that, we change the scripts
section of package.json
to have:
"start": "nodemon --exec npm run babel-node -- ./bin/www",
"babel-node": "babel-node"
This lets us run our app with Babel instead of the regular Node runtime.
Then we need to create a .babelrc
file in the backend
folder and add:
{
"presets": [
"@babel/preset-env"
]
}
This specifies that we run our app with the latest version of JavaScript.
Next we add our database code. Run npx sequelize-cli init
in the backend
folder to generate the Sequelize code.
We should have a config.js
in the project now. In there add:
{
"development": {
"dialect": "sqlite",
"storage": "development.db"
},
"test": {
"dialect": "sqlite",
"storage": "test.db"
},
"production": {
"dialect": "sqlite",
"storage": "production.db"
}
}
This declares SQLite for our database.
Next, create our model and migration by running:
npx sequelize-cli model:create --name Document --attributes name:string,document:text,pdfPath:string
This creates a Document
model and Documents
table.
To create our database, we then run:
npx sequelize-cli db:migrate
Now we need create our routes. Create a pdf.js
file in the routes
folder and add:
var express = require("express");
var pdf = require("html-pdf");
const models = require("../models");
var multer = require("multer");
const fs = require("fs");
var router = express.Router();
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, "./files");
},
filename: (req, file, cb) => {
cb(null, `${file.fieldname}_${+new Date()}.jpg`);
}
});
const upload = multer({
storage
});
router.get("/", async (req, res, next) => {
const documents = await models.Document.findAll();
res.json(documents);
});
router.post("/", async (req, res, next) => {
const document = await models.Document.create(req.body);
res.json(document);
});
router.put("/:id", async (req, res, next) => {
const id = req.params.id;
const { name, document } = req.body;
const doc = await models.Document.update(
{ name, document },
{ where: { id } }
);
res.json(doc);
});
router.delete("/:id", async (req, res, next) => {
const id = req.params.id;
await models.Document.destroy({ where: { id } });
res.json({});
});
router.get("/generatePdf/:id", async (req, res, next) => {
const id = req.params.id;
const documents = await models.Document.findAll({ where: { id } });
const document = documents[0];
const stream = await new Promise((resolve, reject) => {
pdf.create(document.document).toStream((err, stream) => {
if (err) {
reject(reject);
return;
}
resolve(stream);
});
});
const fileName = `${+new Date()}.pdf`;
const pdfPath = `${__dirname}/../files/${fileName}`;
stream.pipe(fs.createWriteStream(pdfPath));
const doc = await models.Document.update(
{ pdfPath: fileName },
{ where: { id } }
);
res.json(doc);
});
router.post("/uploadImage", upload.single('upload'), async (req, res, next) => {
res.json({
uploaded: true,
url: `${process.env.BASE_URL}/${req.file.filename}`
});
});
module.exports = router;
We perform the standard CRUD operations to the Documents
table in the first 4 routes. We have GET for fetching all the Documents
, POST for creating a Document
from post parameters, PUT for updating a Document
by ID, and DELETE for deleting a Document
by looking it up by ID. We have the HTML in the document
field for generating the PDF later.
The generatePdf
is the function that allows us to build the PDF. We get the ID from the URL and then use the HTML-PDF package to generate the PDF. We generate the PDF by converting the HTML document to a file stream object with the HTML-PDF package. Then we write the stream to a file and save the path to the file in the Document
model with the ID in the URL parameter.
We also have an uploadImage
route to let user upload images with CKEditor. The plugin expects uploaded
and url
in the response which our function returns.
Then we need to add a files
folder in the backend
directory.
Next in app.js
, we replace the existing code with:
var createError = require("http-errors");
var express = require("express");
var path = require("path");
var cookieParser = require("cookie-parser");
var logger = require("morgan");
var cors = require("cors");
var indexRouter = require("./routes/index");
var pdfRouter = require("./routes/pdf");
var app = express();
// view engine setup
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "jade");
app.use(logger("dev"));
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(express.static(path.join(__dirname, "public")));
app.use(express.static(path.join(__dirname, "files")));
app.use(cors());
app.use("/", indexRouter);
app.use("/pdf", pdfRouter);
// catch 404 and forward to error handler
app.use(function(req, res, next) {
next(createError(404));
});
// error handler
app.use(function(err, req, res, next) {
// set locals, only providing error in development
res.locals.message = err.message;
res.locals.error = req.app.get("env") === "development" ? err : {};
// render the error page
res.status(err.status || 500);
res.render("error");
});
module.exports = app;
We expose the file folder with:
app.use(express.static(path.join(__dirname, "files")));
And expose the pdf
route with:
var pdfRouter = require("./routes/pdf");
app.use("/pdf", pdfRouter);
Frontend
Now that the backend is complte, we can move onto the front end. Create the React app by using Create React App. We run npx create-react-app frontend
in the root folder of the project.
We then install our packages. We will use CKEditor for our rich text editor, Axios for making HTTP requests, Bootstrap for styling, MobX for simple state management, React Router for routing URLs to components, and Formik and Yup for form value handling and form validation respectively.
Install all the packages by running npm i @ckeditor/ckeditor5-build-classic @ckeditor/ckeditor5-react axios bootstrap formik mobx mobx-react react-bootstrap react-router-dom yup
.
With the packages installed, we can get started. In App.js
, we replace the existing code with:
import React from "react";
import HomePage from "./HomePage";
import { Router, Route } from "react-router-dom";
import { createBrowserHistory as createHistory } from "history";
import TopBar from "./TopBar";
import { DocumentStore } from "./store";
import "./App.css";
const history = createHistory();
const documentStore = new DocumentStore();
function App() {
return (
<div className="App">
<Router history={history}>
<TopBar />
<Route
path="/"
exact
component={props => (
<HomePage {...props} documentStore={documentStore} />
)}
/>
</Router>
</div>
);
}
export default App;
This adds our top bar and route to the home page.
In App.css
, we replace the existing code with:
.page {
padding: 20px;
}
.content-invalid-feedback {
width: 100%;
margin-top: 0.25rem;
font-size: 80%;
color: #dc3545;
}
nav.navbar {
background-color: green !important;
}
This adds some padding to our page and style the validation message for the Rich text editor and changes the color of the navbar
.
Next we create the form for adding and editing documents. Create a DocumentForm.js
in the src
file and add:
import React from "react";
import * as yup from "yup";
import Form from "react-bootstrap/Form";
import Col from "react-bootstrap/Col";
import Button from "react-bootstrap/Button";
import { observer } from "mobx-react";
import { Formik, Field } from "formik";
import { addDocument, editDocument, getDocuments, APIURL } from "./request";
import CKEditor from "@ckeditor/ckeditor5-react";
import ClassicEditor from "@ckeditor/ckeditor5-build-classic";
const schema = yup.object({
name: yup.string().required("Name is required")
});
function DocumentForm({ documentStore, edit, onSave, doc }) {
const [content, setContent] = React.useState("");
const [dirty, setDirty] = React.useState(false);
const handleSubmit = async evt => {
const isValid = await schema.validate(evt);
if (!isValid || !content) {
return;
}
const data = { ...evt, document: content };
if (!edit) {
await addDocument(data);
} else {
await editDocument(data);
}
getAllDocuments();
};
const getAllDocuments = async () => {
const response = await getDocuments();
documentStore.setDocuments(response.data);
onSave();
};
return (
<>
<Formik
validationSchema={schema}
onSubmit={handleSubmit}
initialValues={edit ? doc : {}}
>
{({
handleSubmit,
handleChange,
handleBlur,
values,
touched,
isInvalid,
errors
}) => (
<Form noValidate onSubmit={handleSubmit}>
<Form.Row>
<Form.Group as={Col} md="12" controlId="name">
<Form.Label>Name</Form.Label>
<Form.Control
type="text"
name="name"
placeholder="Name"
value={values.name || ""}
onChange={handleChange}
isInvalid={touched.name && errors.name}
/>
<Form.Control.Feedback type="invalid">
{errors.name}
</Form.Control.Feedback>
</Form.Group>
</Form.Row>
<Form.Row>
<Form.Group as={Col} md="12" controlId="content">
<Form.Label>Content</Form.Label>
<CKEditor
editor={ClassicEditor}
data={content || ""}
onInit={editor => {
if (edit) {
setContent(doc.document);
}
}}
onChange={(event, editor) => {
const data = editor.getData();
setContent(data);
setDirty(true);
}}
config={{
ckfinder: {
uploadUrl:
`${APIURL}/pdf/uploadImage`
}
}}
/>
<div className="content-invalid-feedback">
{dirty && !content ? "Content is required" : null}
</div>
</Form.Group>
</Form.Row>
<Button type="submit" style={{ marginRight: 10 }}>
Save
</Button>
<Button type="button">Cancel</Button>
</Form>
)}
</Formik>
</>
);
}
export default observer(DocumentForm);
We wrap our React Bootstrap Form
inside the Formik
component to get the form handling function from Formik which we use directly in the React Bootstrap form fields.
We cannot do the same with CKEditor, so we write our own form handlers for the rich text editor. We set the data
prop in the CKEditor
to set the value of the input of the rich text editor. The onInit
function is used when users try to edit an existing document since we have to set the data
prop with the editor initializes by running setContent(doc.document);
. The onChange
prop is the handler function for setting content
whenever it is updated so the data
prop will have the latest value, which we will submit when the user clicks Save.
We use the CKFinder plugin to upload images. To make it work, we set the image upload URL to the URL of the upload route in our back end.
The form validation schema is provided by the Yup schema
object which we create at the top of the code. We check if the name
field exists.
The handleSubmit
function is for submitting the data to back end. We check both the content
and the evt
object to see both fields exists since we cannot incorporate the Formik form handlers directly into the CKEditor
component.
If everything is valid, then we add a new document or update the document depending on whether the edit
prop is true
or not.
When saving is successful, we call getAllDocuments
to populate the latest documents into our MobX store by running documentStore.setDocuments(response.data);
.
Next, we build our home page by creating HomePage.js
in the src
folder and add:
import React, { useState, useEffect } from "react";
import { withRouter } from "react-router-dom";
import DocumentForm from "./DocumentForm";
import Modal from "react-bootstrap/Modal";
import ButtonToolbar from "react-bootstrap/ButtonToolbar";
import Button from "react-bootstrap/Button";
import Table from "react-bootstrap/Table";
import { observer } from "mobx-react";
import { getDocuments, deleteDocument, generatePDF, APIURL } from "./request";
function HomePage({ documentStore, history }) {
const [openAddModal, setOpenAddModal] = useState(false);
const [openEditModal, setOpenEditModal] = useState(false);
const [initialized, setInitialized] = useState(false);
const [doc, setDoc] = useState([]);
const openAddTemplateModal = () => {
setOpenAddModal(true);
};
const closeAddModal = () => {
setOpenAddModal(false);
setOpenEditModal(false);
};
const cancelAddModal = () => {
setOpenAddModal(false);
};
const cancelEditModal = () => {
setOpenEditModal(false);
};
const getAllDocuments = async () => {
const response = await getDocuments();
documentStore.setDocuments(response.data);
setInitialized(true);
};
const editTemplate = d => {
setDoc(d);
setOpenEditModal(true);
};
const onSave = () => {
cancelAddModal();
cancelEditModal();
};
const deleteSingleDocument = async id => {
await deleteDocument(id);
getAllDocuments();
};
const generateSinglePdf = async id => {
await generatePDF(id);
alert("PDF Generated");
getAllDocuments();
};
useEffect(() => {
if (!initialized) {
getAllDocuments();
}
});
return (
<div className="page">
<h1 className="text-center">Documents</h1>
<ButtonToolbar onClick={openAddTemplateModal}>
<Button variant="primary">Add Document</Button>
</ButtonToolbar>
<Modal show={openAddModal} onHide={closeAddModal}>
<Modal.Header closeButton>
<Modal.Title>Add Document</Modal.Title>
</Modal.Header>
<Modal.Body>
<DocumentForm
onSave={onSave.bind(this)}
cancelModal={cancelAddModal.bind(this)}
documentStore={documentStore}
/>
</Modal.Body>
</Modal>
<Modal show={openEditModal} onHide={cancelEditModal}>
<Modal.Header closeButton>
<Modal.Title>Edit Document</Modal.Title>
</Modal.Header>
<Modal.Body>
<DocumentForm
edit={true}
doc={doc}
onSave={onSave.bind(this)}
cancelModal={cancelEditModal.bind(this)}
documentStore={documentStore}
/>
</Modal.Body>
</Modal>
<br />
<Table striped bordered hover>
<thead>
<tr>
<th>Name</th>
<th>PDF</th>
<th>Generate PDF</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{documentStore.documents.map(d => {
return (
<tr key={d.id}>
<td>{d.name}</td>
<td>
<a href={`${APIURL}/${d.pdfPath}`} target="_blank">
Open
</a>
</td>
<td>
<Button
variant="outline-primary"
onClick={generateSinglePdf.bind(this, d.id)}
>
Generate PDF
</Button>
</td>
<td>
<Button
variant="outline-primary"
onClick={editTemplate.bind(this, d)}
>
Edit
</Button>
</td>
<td>
<Button
variant="outline-primary"
onClick={deleteSingleDocument.bind(this, d.id)}
>
Delete
</Button>
</td>
</tr>
);
})}
</tbody>
</Table>
</div>
);
}
export default withRouter(observer(HomePage));
We have a React Bootstrap table for listing the documents with buttons to edit or delete documents, and a button to generate a PDF. Also, there is an ppen link for opening the PDF in each row. We have a button to create a PDF on top of the table.
When the page loads, we call getAllDocuments
and populate them in the MobX store. We open and close the add and edit modals with the openAddTemplateModal
, closeAddModal
, cancelAddModal
, cancelEditModal
functions.
Next create request.js
in the src
folder and add:
export const APIURL = "http://localhost:3000";
const axios = require("axios");
export const getDocuments = () => axios.get(`${APIURL}/pdf`);
export const addDocument = data => axios.post(`${APIURL}/pdf`, data);
export const editDocument = data => axios.put(`${APIURL}/pdf/${data.id}`, data);
export const deleteDocument = id => axios.delete(`${APIURL}/pdf/${id}`);
export const generatePDF = id => axios.get(`${APIURL}/pdf/generatePdf/${id}`);
to add the functions to make requests to our routes in the back end.
Then we create our MobX store. Create store.js
in the src
folder and put:
import { observable, action, decorate } from "mobx";
class DocumentStore {
documents = [];
setDocuments(documents) {
this.documents = documents;
}
}
DocumentStore = decorate(DocumentStore, {
documents: observable,
setDocuments: action
});
export { DocumentStore };
We have the function setDocuments
to put the photo data in the store, which we used in HomePage
and DocumentForm
, and we instantiated it before exporting so that we only have to do it in one place.
This block:
DocumentStore = decorate(DocumentStore, {
documents: observable,
setDocuments: action
});
designates the documents
array in DocumentStore
as the entity that can be watched by components for changes. The setDocuments
function is designated as the function that can be used to set the documents
array in the store.
Next we create the top bar by creating a TopBar.js
file in the src
folder and add:
import React from "react";
import Navbar from "react-bootstrap/Navbar";
import Nav from "react-bootstrap/Nav";
import { withRouter } from "react-router-dom";
function TopBar({ location }) {
return (
<Navbar bg="primary" expand="lg" variant="dark">
<Navbar.Brand href="#home">PDF App</Navbar.Brand>
<Navbar.Toggle aria-controls="basic-navbar-nav" />
<Navbar.Collapse id="basic-navbar-nav">
<Nav className="mr-auto">
<Nav.Link href="/" active={location.pathname == "/"}>
Home
</Nav.Link>
</Nav>
</Navbar.Collapse>
</Navbar>
);
}
export default withRouter(TopBar);
This contains the React Bootstrap Navbar
to show a top bar with a link to the home page and the name of the app. We only display it with the token
present in local storage. We check the pathname
to highlight the right links by setting the active
prop.
Next in index.html
, we replace the existing code with:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>PDF App</title>
<link
rel="stylesheet"
href="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/css/bootstrap.min.css"
integrity="sha384-ggOyR0iXCbMQv3Xipma34MD+dH/1fQ784/j6cY/iJTQUOhcWr7x9JvoRxT2MZw1T"
crossorigin="anonymous"
/>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
This adds the Bootstrap CSS and change the title.
After writing all that code, we can run our app. Before running anything, install nodemon
by running npm i -g nodemon
so that we don’t have to restart back end ourselves when files change.
Then start backend by running npm start
in the backend
folder and npm start
in the frontend
folder, then choose ‘yes’ if you’re asked to run it from a different port.
Then you get: