Convert text to handwritten pages using NextJS

Eugene Musebe

Introduction

You've probably seen some of those images where the text looks like it was handwritten then scanned into an image. In this tutorial, let's explore a fun little trick to achieve the same using handwritten.js, cloudinary and next.js.

Cloudinary provides APIs that offer media upload and storage, optimization, manipulation, and delivery.

Codesandbox

The final project can be viewed on Codesandbox.

Prerequisites and setup

Knowledge of javascript is required for this tutorial. In addition, you are required to at least have basic knowledge of React.js and Node.js. You also need to have Node.js and NPM installed in your development environment.

We begin by creating a new project. Next.js has a handy CLI tool that scaffolds a basic project for us. Run the following command.

1npx create-next-app text-to-handwritten-page

text-to-handwritten-page is our project name. Feel free to use any suitable name here. Once the CLI is done scaffolding the project, change the directory into your new project and open it in your favorite code editor.

Dependencies

For this short tutorial, we'll be using the following libraries/packages

Run the following command to install the two

1npm install cloudinary handwritten.js

Cloudinary API Keys

In case you don't have a cloudinary account yet, you can easily get started with a free tier account. You'll be allocated a number of credits for use. Use them carefully and sparingly since you'll probably be charged when they run out. Open up cloudinary, create an account if you don't have one, and log in. Head over to the console page. Here, you'll find your cloud name, api key, and api secret.

In your code editor with your project open, create a new file named .local.env at the root of your project. Paste the following inside.

1CLOUD_NAME=YOUR_CLOUD_NAME
2
3API_KEY=YOUR_API_KEY
4
5API_SECRET=YOUR_API_SECRET

Replace YOUR_CLOUD_NAME YOUR_API_KEY and YOUR_API_SECRET in the .env.local file with the values that we just got from the console page.

We've just defined those values as environment variables. Luckily, Next.js has built-in support for environment variables. Read all about that and advanced options in the documentation

Getting started

Let's first write the code we need to communicate with cloudinary. Create a new folder at the root of your project and name it lib. We'll store our shared code inside this folder. Create a new file called cloudinary.js inside the lib folder and paste the following code inside.

1// lib/cloudinary.js
2
3
4
5// Import the v2 api and rename it to cloudinary
6
7import { v2 as cloudinary, TransformationOptions } from "cloudinary";
8
9
10
11// Initialize the SDK with cloud_name, api_key, and api_secret
12
13cloudinary.config({
14
15cloud_name: process.env.CLOUD_NAME,
16
17api_key: process.env.API_KEY,
18
19api_secret: process.env.API_SECRET,
20
21});
22
23
24
25const CLOUDINARY_FOLDER_NAME = "text-to-handwriting/";
26
27
28
29/**
30
31* Gets a resource from cloudinary using its public id
32
33*
34
35* @param {string} publicId The public id of the image
36
37*/
38
39export const handleGetCloudinaryResource = (publicId) => {
40
41return cloudinary.api.resource(publicId, {
42
43resource_type: "image",
44
45type: "upload",
46
47});
48
49};
50
51
52
53/**
54
55* Get cloudinary uploads
56
57* @returns {Promise}
58
59*/
60
61export const handleGetCloudinaryUploads = () => {
62
63return cloudinary.api.resources({
64
65type: "upload",
66
67prefix: CLOUDINARY_FOLDER_NAME,
68
69resource_type: "image",
70
71});
72
73};
74
75
76
77/**
78
79* Uploads an image to cloudinary and returns the upload result
80
81*
82
83* @param {{file: string | Buffer; publicId?: string; folder?: boolean; }} resource
84
85*/
86
87export const handleCloudinaryUpload = (resource) => {
88
89return cloudinary.uploader.upload(resource.file, {
90
91// Folder to store the image in
92
93folder: resource.folder ? CLOUDINARY_FOLDER_NAME : null,
94
95// Public id of image.
96
97public_id: resource.publicId,
98
99// Type of resource
100
101resource_type: "auto",
102
103});
104
105};
106
107
108
109/**
110
111* Deletes resources from cloudinary. Takes in an array of public ids
112
113* @param {string[]} ids
114
115*/
116
117export const handleCloudinaryDelete = (ids) => {
118
119return cloudinary.api.delete_resources(ids, {
120
121resource_type: "image",
122
123});
124
125};

Let's go over what's happening here. At the very top, we import the v2 API from the cloudinary SDK and rename it to cloudinary. The next thing we do is to call the config method on the SDK to initialize it. To this, we pass the cloud_name, api_key, and api_secret. We defined this as environment variables earlier. Just after that, we define a folder where all our images are going to be stored. Storing all our images in one folder makes it easier to fetch all uploaded images. In a real-world application, you would probably want to store the link in a database or something. Since this is just a tutorial without user authentication or anything of the sort, just fetching all uploaded images works just fine.

The handleGetCloudinaryResource, handleCloudinaryUpload and handleCloudinaryDelete methods just call the get resources, upload and delete APIs respectively.

handleGetCloudinaryResource will call the api.resources method on the SDK to fetch all images uploaded to our folder.

handleCloudinaryUpload will call the uploader.upload method on the SDK to upload a resource. It takes in a resource object containing file which can either be a base64 string, path, or buffer. The object may also contain publicId if you don't want cloudinary to provide a random id for you.


Next, create a new folder called images under pages/api/. Create a new file called index.js under pages/api/images. This file will handle calls to the /api/images endpoint. API routes are a core part of Next.js. If you're not familiar, I recommend you have a read-through this documentation. Paste the following code inside pages/api/images/index.js

1// pages/api/images/index.js
2
3
4
5import { NextApiRequest, NextApiResponse } from "next";
6
7import handwritten from "handwritten.js";
8
9import COLORS from "handwritten.js/src/constants";
10
11import {
12
13handleCloudinaryUpload,
14
15handleGetCloudinaryUploads,
16
17} from "../../../lib/cloudinary";
18
19
20
21/**
22
23* Endpoint handler
24
25* @param {NextApiRequest} req
26
27* @param {NextApiResponse} res
28
29*/
30
31export default async function handler(req, res) {
32
33// Switch based on the request method
34
35switch (req.method) {
36
37case "GET": {
38
39try {
40
41const result = await handleGetRequest();
42
43
44
45return res.status(200).json({ message: "Success", result });
46
47} catch (error) {
48
49console.error(error);
50
51return res.status(400).json({ message: "Error", error });
52
53}
54
55}
56
57
58
59case "POST": {
60
61try {
62
63const result = await handlePostRequest(req.body);
64
65
66
67return res.status(201).json({ message: "Success", result });
68
69} catch (error) {
70
71console.error(error);
72
73return res.status(400).json({ message: "Error", error });
74
75}
76
77}
78
79
80
81default: {
82
83return res.status(405).json({ message: "Method not allowed" });
84
85}
86
87}
88
89}
90
91
92
93const handleGetRequest = async () => {
94
95// Get all the uploads
96
97const result = await handleGetCloudinaryUploads();
98
99
100
101return result;
102
103};
104
105
106
107const handlePostRequest = async (body) => {
108
109const { text, color, ruled } = body;
110
111
112
113// Convert text to handwritten image.
114
115const [base64Image] = await handwritten(text, {
116
117ruled,
118
119outputType: "png/b64",
120
121inkColor: color,
122
123});
124
125
126
127// Upload the image to cloudinary
128
129const uploadResponse = await handleCloudinaryUpload({
130
131file: base64Image,
132
133folder: true,
134
135});
136
137
138
139return uploadResponse;
140
141};

The structure of a Next.js API route is very simple. You just need to have a default export that is a function. The function can take in the incoming request object and the outgoing response object. In the code above, we use a switch statement so that we can only handle GET and POST requests. When a GET request is made to the /api/images endpoint, we want to fetch all uploaded resources. This is handled by the handleGetRequest function. When a POST request is made to the /api/images endpoint, we want to create and upload an image. This is handled by the handlePostRequest function.

handlePostRequest takes in the incoming request body. We use object destructuring to get the text, color of the text, and whether the page should be ruled or not. We then import the handwritten library at the top and use it to convert the text into an image of a handwritten page. You can read more about the options passed to handwritten() from the github docs. The docs also state that, when the output type is pdf we get an instance of PDFKit, however, when it's an image, we get an array containing either the base64 string or Buffer array. We proceed to use array destructuring to get the base64 string. We then upload that to cloudinary using the handleCloudinaryUpload method.

Create a new file called [...id].js inside pages/api/images folder. This file will handle api requests made to the /api/images/:id endpoint. Paste the following code inside

1import { NextApiRequest, NextApiResponse } from "next";
2
3import { handleCloudinaryDelete } from "../../../lib/cloudinary";
4
5
6
7/**
8
9*
10
11* @param {NextApiRequest} req
12
13* @param {NextApiResponse} res
14
15*/
16
17export default async function handler(req, res) {
18
19let { id } = req.query;
20
21
22
23if (!id) {
24
25res.status(400).json({ error: "Missing id" });
26
27return;
28
29}
30
31
32
33if (Array.isArray(id)) {
34
35id = id.join("/");
36
37}
38
39
40
41switch (req.method) {
42
43case "DELETE": {
44
45try {
46
47const result = await handleDeleteRequest(id);
48
49
50
51return res.status(200).json({ message: "Success", result });
52
53} catch (error) {
54
55console.error(error);
56
57return res.status(400).json({ message: "Error", error });
58
59}
60
61}
62
63
64
65default: {
66
67return res.status(405).json({ message: "Method not allowed" });
68
69}
70
71}
72
73}
74
75
76
77const handleDeleteRequest = async (id) => {
78
79const result = await handleCloudinaryDelete([id]);
80
81
82
83return result;
84
85};

This is very similar to pages/api/images/index.js only that this time we're only handling DELETE requests. You'll also notice the weird file name. This is all part of Next.js API routes. When we have dynamic routes such as /api/images/:id we can handle them by using the syntax [id].js to name the file that will be handling the route. Okay, so what about the syntax we used for this file? Sometimes, you want to catch all other routes following your dynamic part. For example /api/images/:id/:anotherId. To catch all routes after the :id you want to use [...id].js. Read this documentation to get a better explanation.


Moving on to the front end. Add the following code inside styles/globals.css.

1:root {
2
3--color-primary: #0070f3;
4
5}
6
7
8
9.btn {
10
11background-color: var(--color-primary);
12
13border-radius: 5px;
14
15border: none;
16
17color: #fff;
18
19text-transform: uppercase;
20
21padding: 1rem;
22
23font-size: 1rem;
24
25font-weight: 700;
26
27cursor: pointer;
28
29transition: all 0.2s;
30
31min-width: 50px;
32
33}
34
35
36
37.danger {
38
39background-color: #cc0000;
40
41}
42
43
44
45.btn:hover:not([disabled]) {
46
47filter: brightness(90%);
48
49box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.2);
50
51}
52
53
54
55.btn:disabled {
56
57opacity: 0.5;
58
59cursor: not-allowed;
60
61}

These are just a few styles to help us with the UI.

Create a new folder at the root of your project and name it components. This folder will hold shared components. Create a new file inside and name it Layout.js and paste the following code inside.

1// components/Layout.js
2
3
4
5import Head from "next/head";
6
7import Link from "next/link";
8
9
10
11export default function Layout({ children }) {
12
13return (
14
15<div>
16
17<Head>
18
19<title>Text to handwritten page</title>
20
21<meta name="description" content="Text to handwritten page" />
22
23<link rel="icon" href="/favicon.ico" />
24
25</Head>
26
27<nav>
28
29<ul>
30
31<li>
32
33<Link href="/">
34
35<a className="btn">Home</a>
36
37</Link>
38
39</li>
40
41<li>
42
43<Link href="/images">
44
45<a className="btn">Images</a>
46
47</Link>
48
49</li>
50
51</ul>
52
53</nav>
54
55<main>{children}</main>
56
57<style jsx>{`
58
59nav {
60
61height: 100px;
62
63background-color: #f4f4f4;
64
65}
66
67
68
69nav ul {
70
71height: 100%;
72
73width: 100%;
74
75list-style: none;
76
77margin: 0;
78
79display: flex;
80
81justify-content: center;
82
83align-items: center;
84
85gap: 10px;
86
87}
88
89
90
91main {
92
93min-height: calc(100vh - 100px);
94
95}
96
97`}</style>
98
99</div>
100
101);
102
103}

We'll use this component to wrap our pages so that we have a consistent layout and also so that we can avoid code duplication.

TIP: You can give your frontend components the .jsx extension for better IntelliSense and code completion

Paste the following code inside pages/index.js.

1import { useRouter } from "next/router";
2
3import { useState } from "react";
4
5import Layout from "../components/Layout";
6
7
8
9export default function Home() {
10
11const router = useRouter();
12
13const [submitting, setSubmitting] = useState(false);
14
15const handleFormSubmit = async (e) => {
16
17e.preventDefault();
18
19
20try {
21
22setSubmitting(true);
23
24const formData = new FormData(e.target);
25
26const response = await fetch("/api/images", {
27
28method: "POST",
29
30body: JSON.stringify({
31
32text: formData.get("text"),
33
34color: formData.get("color"),
35
36ruled: formData.get("ruled") === "on",
37
38}),
39
40headers: {
41
42"Content-Type": "application/json",
43
44},
45
46});
47
48
49
50const data = await response.json();
51
52
53
54if (!response.ok) {
55
56throw data;
57
58}
59
60
61
62router.push("/images");
63
64} catch (error) {
65
66console.error(error);
67
68} finally {
69
70setSubmitting(false);
71
72}
73
74};
75
76
77
78return (
79
80<Layout>
81
82<div className="wrapper">
83
84<h1>Convert text to handwritten page photo</h1>
85
86<form onSubmit={handleFormSubmit}>
87
88<div className="input-wrapper">
89
90<label htmlFor="text">Text</label>
91
92<textarea
93
94name="text"
95
96id="text"
97
98cols="30"
99
100rows="10"
101
102placeholder="Input your text here"
103
104required
105
106></textarea>
107
108</div>
109
110<div className="input-wrapper inline">
111
112<label htmlFor="ruled">Ruled Page: </label>
113
114<input
115
116type="checkbox"
117
118name="ruled"
119
120id="ruled"
121
122defaultChecked={true}
123
124disabled={submitting}
125
126/>
127
128</div>
129
130<div className="input-wrapper">
131
132<label htmlFor="color">Text Color</label>
133
134<select
135
136name="color"
137
138id="color"
139
140defaultValue="black"
141
142required
143
144disabled={submitting}
145
146>
147
148<option value="black">Black</option>
149
150<option value="red">Red</option>
151
152<option value="blue">Blue</option>
153
154</select>
155
156</div>
157
158<button className="btn" type="submit" disabled={submitting}>
159
160Convert Text
161
162</button>
163
164</form>
165
166</div>
167
168<style jsx>{`
169
170.wrapper {
171
172display: flex;
173
174flex-direction: column;
175
176align-items: center;
177
178justify-content: center;
179
180}
181
182
183
184form {
185
186width: 60%;
187
188max-width: 600px;
189
190padding: 20px;
191
192display: flex;
193
194flex-direction: column;
195
196gap: 20px;
197
198border-radius: 5px;
199
200background-color: #fafafa;
201
202box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
203
204}
205
206
207
208form .input-wrapper {
209
210display: flex;
211
212flex-direction: column;
213
214gap: 10px;
215
216}
217
218
219
220form .input-wrapper.inline {
221
222flex-direction: row;
223
224justify-content: flex-start;
225
226align-items: center;
227
228}
229
230
231
232form .input-wrapper label {
233
234font-size: 14px;
235
236}
237
238
239
240form .input-wrapper textarea,
241
242form .input-wrapper select,
243
244form .input-wrapper input {
245
246border: none;
247
248outline: none;
249
250border-radius: 5px;
251
252padding: 5px;
253
254min-height: 50px;
255
256}
257
258
259
260form .input-wrapper input[type="checkbox"] {
261
262height: 20px;
263
264width: 20px;
265
266}
267
268
269
270form .input-wrapper textarea:focus {
271
272outline: 2px solid var(--color-primary);
273
274}
275
276`}</style>
277
278</Layout>
279
280);
281
282}

This page contains a form that will trigger the handleFormSubmit method on submission. handleFormSubmit posts the data to the /api/images endpoint that we created earlier than on success it navigates to the /images page that we're going to create next.

Create a new file inside the pages/ folder and call it images.js. Paste the following code inside pages/images.js.

1import { useCallback, useEffect, useState } from "react";
2
3import Link from "next/link";
4
5import Image from "next/image";
6
7import Layout from "../components/Layout";
8
9
10
11export default function Images() {
12
13const [loading, setLoading] = useState(false);
14
15const [images, setImages] = useState([]);
16
17
18
19const getImages = useCallback(async () => {
20
21try {
22
23setLoading(true);
24
25
26
27const response = await fetch("/api/images", {
28
29method: "GET",
30
31});
32
33
34
35const data = await response.json();
36
37
38
39if (!response.ok) {
40
41throw data;
42
43}
44
45
46
47setImages(data.result.resources);
48
49} catch (error) {
50
51console.error(error);
52
53} finally {
54
55setLoading(false);
56
57}
58
59}, []);
60
61
62
63useEffect(() => {
64
65getImages();
66
67}, [getImages]);
68
69
70
71const handleDownloadResource = async (url) => {
72
73try {
74
75setLoading(true);
76
77
78
79const response = await fetch(url, {});
80
81
82
83if (response.ok) {
84
85const blob = await response.blob();
86
87
88
89const fileUrl = URL.createObjectURL(blob);
90
91
92
93const a = document.createElement("a");
94
95a.href = fileUrl;
96
97a.download = `my-virtual-tag.png`;
98
99document.body.appendChild(a);
100
101a.click();
102
103a.remove();
104
105return;
106
107}
108
109
110
111throw await response.json();
112
113} catch (error) {
114
115// TODO: Show error message to user
116
117console.error(error);
118
119} finally {
120
121setLoading(false);
122
123}
124
125};
126
127
128
129const handleDelete = async (id) => {
130
131try {
132
133setLoading(true);
134
135
136
137const response = await fetch(`/api/images/${id}`, {
138
139method: "DELETE",
140
141});
142
143
144
145const data = await response.json();
146
147
148
149if (!response.ok) {
150
151throw data;
152
153}
154
155
156
157getImages();
158
159} catch (error) {
160
161} finally {
162
163setLoading(false);
164
165}
166
167};
168
169
170
171return (
172
173<Layout>
174
175{images.length > 0 ? (
176
177<div className="wrapper">
178
179<div className="images-wrapper">
180
181{images.map((image) => {
182
183return (
184
185<div className="image-wrapper" key={image.public_id}>
186
187<div className="image">
188
189<Image
190
191src={image.secure_url}
192
193width={image.width}
194
195height={image.height}
196
197layout="responsive"
198
199alt={image.secure_url}
200
201></Image>
202
203</div>
204
205<div className="actions">
206
207<button
208
209className="btn"
210
211disabled={loading}
212
213onClick={() => {
214
215handleDownloadResource(image.secure_url);
216
217}}
218
219>
220
221Download
222
223</button>
224
225<button
226
227className="btn danger"
228
229disabled={loading}
230
231onClick={() => {
232
233handleDelete(image.public_id);
234
235}}
236
237>
238
239Delete
240
241</button>
242
243</div>
244
245</div>
246
247);
248
249})}
250
251</div>
252
253</div>
254
255) : null}
256
257{!loading && images.length === 0 ? (
258
259<div className="no-images">
260
261<b>No Images Yet</b>
262
263<Link href="/">
264
265<a className="btn">Convert some text</a>
266
267</Link>
268
269</div>
270
271) : null}
272
273{loading && images.length === 0 ? (
274
275<div className="loading">
276
277<b>Loading...</b>
278
279</div>
280
281) : null}
282
283<style jsx>{`
284
285div.wrapper {
286
287min-height: 100vh;
288
289background-color: #f4f4f4;
290
291}
292
293
294
295div.wrapper div.images-wrapper {
296
297display: flex;
298
299flex-flow: row wrap;
300
301gap: 10px;
302
303padding: 10px;
304
305}
306
307
308
309div.wrapper div.images-wrapper div.image-wrapper {
310
311flex: 0 0 400px;
312
313display: flex;
314
315flex-flow: column;
316
317}
318
319
320
321div.wrapper div.images-wrapper div.image-wrapper div.image {
322
323background-color: #ffffff;
324
325position: relative;
326
327width: 100%;
328
329}
330
331
332
333div.wrapper div.images-wrapper div.image-wrapper div.actions {
334
335background-color: #ffffff;
336
337padding: 10px;
338
339display: flex;
340
341flex-flow: row wrap;
342
343gap: 10px;
344
345}
346
347
348
349div.loading,
350
351div.no-images {
352
353height: 100vh;
354
355display: flex;
356
357align-items: center;
358
359justify-content: center;
360
361flex-flow: column;
362
363gap: 10px;
364
365}
366
367`}</style>
368
369</Layout>
370
371);
372
373}

This page will display all our images. The page uses the useEffect and useCallback react hooks to make a get request to the /api/images endpoint. I won't get into react hooks since there's a lot of resources on them online. A good place to start is the React hooks API reference. handleDelete takes in an image's public id and makes a DELETE request to the /api/images/:id endpoint.

One more thing we need to do. We need to configure Next.js to be able to display images from cloudinary using the Next.js Image component. We're going to be adding the cloudinary domain to next.config.js. Read more about this here. Insert the following code inside next.config.js.

1module.exports = {
2
3// ... other options
4
5images: {
6
7domains: ["res.cloudinary.com"],
8
9},
10
11};

If you can't find the next.config.js file at the root of your project, you can create it yourself.

That is it for this short tutorial. You can now run your application on the development environment using the following command.

1npm run dev

See the Next.js documentation for information on how to build for other environments and also how to optimize your code. It's worth mentioning that this is a very simple implementation and is in no way intended to be applied to a production environment. There are lots of ways you can optimize our implementation for a fast production environment.

Congrats for making it to the end. You can find the full source code on my Github

Eugene Musebe

Software Developer

I’m a full-stack software developer, content creator, and tech community builder based in Nairobi, Kenya. I am addicted to learning new technologies and loves working with like-minded people.