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.
The final project can be viewed on Codesandbox.
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.
npx 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.
For this short tutorial, we’ll be using the following libraries/packages
Run the following command to install the two
npm install cloudinary handwritten.js
Code language: CSS (css)
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.
CLOUD_NAME=YOUR_CLOUD_NAME
API_KEY=YOUR_API_KEY
API_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
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.
// lib/cloudinary.js
// Import the v2 api and rename it to cloudinary
import { v2 as cloudinary, TransformationOptions } from "cloudinary";
// Initialize the SDK with cloud_name, api_key, and api_secret
cloudinary.config({
cloud_name: process.env.CLOUD_NAME,
api_key: process.env.API_KEY,
api_secret: process.env.API_SECRET,
});
const CLOUDINARY_FOLDER_NAME = "text-to-handwriting/";
/**
* Gets a resource from cloudinary using its public id
*
* @param {string} publicId The public id of the image
*/
export const handleGetCloudinaryResource = (publicId) => {
return cloudinary.api.resource(publicId, {
resource_type: "image",
type: "upload",
});
};
/**
* Get cloudinary uploads
* @returns {Promise}
*/
export const handleGetCloudinaryUploads = () => {
return cloudinary.api.resources({
type: "upload",
prefix: CLOUDINARY_FOLDER_NAME,
resource_type: "image",
});
};
/**
* Uploads an image to cloudinary and returns the upload result
*
* @param {{file: string | Buffer; publicId?: string; folder?: boolean; }} resource
*/
export const handleCloudinaryUpload = (resource) => {
return cloudinary.uploader.upload(resource.file, {
// Folder to store the image in
folder: resource.folder ? CLOUDINARY_FOLDER_NAME : null,
// Public id of image.
public_id: resource.publicId,
// Type of resource
resource_type: "auto",
});
};
/**
* Deletes resources from cloudinary. Takes in an array of public ids
* @param {string[]} ids
*/
export const handleCloudinaryDelete = (ids) => {
return cloudinary.api.delete_resources(ids, {
resource_type: "image",
});
};
Code language: JavaScript (javascript)
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
// pages/api/images/index.js
import { NextApiRequest, NextApiResponse } from "next";
import handwritten from "handwritten.js";
import COLORS from "handwritten.js/src/constants";
import {
handleCloudinaryUpload,
handleGetCloudinaryUploads,
} from "../../../lib/cloudinary";
/**
* Endpoint handler
* @param {NextApiRequest} req
* @param {NextApiResponse} res
*/
export default async function handler(req, res) {
// Switch based on the request method
switch (req.method) {
case "GET": {
try {
const result = await handleGetRequest();
return res.status(200).json({ message: "Success", result });
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Error", error });
}
}
case "POST": {
try {
const result = await handlePostRequest(req.body);
return res.status(201).json({ message: "Success", result });
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handleGetRequest = async () => {
// Get all the uploads
const result = await handleGetCloudinaryUploads();
return result;
};
const handlePostRequest = async (body) => {
const { text, color, ruled } = body;
// Convert text to handwritten image.
const [base64Image] = await handwritten(text, {
ruled,
outputType: "png/b64",
inkColor: color,
});
// Upload the image to cloudinary
const uploadResponse = await handleCloudinaryUpload({
file: base64Image,
folder: true,
});
return uploadResponse;
};
Code language: JavaScript (javascript)
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
import { NextApiRequest, NextApiResponse } from "next";
import { handleCloudinaryDelete } from "../../../lib/cloudinary";
/**
*
* @param {NextApiRequest} req
* @param {NextApiResponse} res
*/
export default async function handler(req, res) {
let { id } = req.query;
if (!id) {
res.status(400).json({ error: "Missing id" });
return;
}
if (Array.isArray(id)) {
id = id.join("/");
}
switch (req.method) {
case "DELETE": {
try {
const result = await handleDeleteRequest(id);
return res.status(200).json({ message: "Success", result });
} catch (error) {
console.error(error);
return res.status(400).json({ message: "Error", error });
}
}
default: {
return res.status(405).json({ message: "Method not allowed" });
}
}
}
const handleDeleteRequest = async (id) => {
const result = await handleCloudinaryDelete([id]);
return result;
};
Code language: JavaScript (javascript)
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
.
:root {
--color-primary: #0070f3;
}
.btn {
background-color: var(--color-primary);
border-radius: 5px;
border: none;
color: #fff;
text-transform: uppercase;
padding: 1rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
min-width: 50px;
}
.danger {
background-color: #cc0000;
}
.btn:hover:not([disabled]) {
filter: brightness(90%);
box-shadow: 0px 5px 15px rgba(0, 0, 0, 0.2);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
Code language: CSS (css)
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.
// components/Layout.js
import Head from "next/head";
import Link from "next/link";
export default function Layout({ children }) {
return (
<div>
<Head>
<title>Text to handwritten page</title>
<meta name="description" content="Text to handwritten page" />
<link rel="icon" href="/favicon.ico" />
</Head>
<nav>
<ul>
<li>
<Link href="/">
<a className="btn">Home</a>
</Link>
</li>
<li>
<Link href="/images">
<a className="btn">Images</a>
</Link>
</li>
</ul>
</nav>
<main>{children}</main>
<style jsx>{`
nav {
height: 100px;
background-color: #f4f4f4;
}
nav ul {
height: 100%;
width: 100%;
list-style: none;
margin: 0;
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
}
main {
min-height: calc(100vh - 100px);
}
`}</style>
</div>
);
}
Code language: JavaScript (javascript)
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
.
import { useRouter } from "next/router";
import { useState } from "react";
import Layout from "../components/Layout";
export default function Home() {
const router = useRouter();
const [submitting, setSubmitting] = useState(false);
const handleFormSubmit = async (e) => {
e.preventDefault();
try {
setSubmitting(true);
const formData = new FormData(e.target);
const response = await fetch("/api/images", {
method: "POST",
body: JSON.stringify({
text: formData.get("text"),
color: formData.get("color"),
ruled: formData.get("ruled") === "on",
}),
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
if (!response.ok) {
throw data;
}
router.push("/images");
} catch (error) {
console.error(error);
} finally {
setSubmitting(false);
}
};
return (
<Layout>
<div className="wrapper">
<h1>Convert text to handwritten page photo</h1>
<form onSubmit={handleFormSubmit}>
<div className="input-wrapper">
<label htmlFor="text">Text</label>
<textarea
name="text"
id="text"
cols="30"
rows="10"
placeholder="Input your text here"
required
></textarea>
</div>
<div className="input-wrapper inline">
<label htmlFor="ruled">Ruled Page: </label>
<input
type="checkbox"
name="ruled"
id="ruled"
defaultChecked={true}
disabled={submitting}
/>
</div>
<div className="input-wrapper">
<label htmlFor="color">Text Color</label>
<select
name="color"
id="color"
defaultValue="black"
required
disabled={submitting}
>
<option value="black">Black</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
</select>
</div>
<button className="btn" type="submit" disabled={submitting}>
Convert Text
</button>
</form>
</div>
<style jsx>{`
.wrapper {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
form {
width: 60%;
max-width: 600px;
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
border-radius: 5px;
background-color: #fafafa;
box-shadow: 0px 0px 10px rgba(0, 0, 0, 0.1);
}
form .input-wrapper {
display: flex;
flex-direction: column;
gap: 10px;
}
form .input-wrapper.inline {
flex-direction: row;
justify-content: flex-start;
align-items: center;
}
form .input-wrapper label {
font-size: 14px;
}
form .input-wrapper textarea,
form .input-wrapper select,
form .input-wrapper input {
border: none;
outline: none;
border-radius: 5px;
padding: 5px;
min-height: 50px;
}
form .input-wrapper input[type="checkbox"] {
height: 20px;
width: 20px;
}
form .input-wrapper textarea:focus {
outline: 2px solid var(--color-primary);
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
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
.
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import Layout from "../components/Layout";
export default function Images() {
const [loading, setLoading] = useState(false);
const [images, setImages] = useState([]);
const getImages = useCallback(async () => {
try {
setLoading(true);
const response = await fetch("/api/images", {
method: "GET",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
setImages(data.result.resources);
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
getImages();
}, [getImages]);
const handleDownloadResource = async (url) => {
try {
setLoading(true);
const response = await fetch(url, {});
if (response.ok) {
const blob = await response.blob();
const fileUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = fileUrl;
a.download = `my-virtual-tag.png`;
document.body.appendChild(a);
a.click();
a.remove();
return;
}
throw await response.json();
} catch (error) {
// TODO: Show error message to user
console.error(error);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
try {
setLoading(true);
const response = await fetch(`/api/images/${id}`, {
method: "DELETE",
});
const data = await response.json();
if (!response.ok) {
throw data;
}
getImages();
} catch (error) {
} finally {
setLoading(false);
}
};
return (
<Layout>
{images.length > 0 ? (
<div className="wrapper">
<div className="images-wrapper">
{images.map((image) => {
return (
<div className="image-wrapper" key={image.public_id}>
<div className="image">
<Image
src={image.secure_url}
width={image.width}
height={image.height}
layout="responsive"
alt={image.secure_url}
></Image>
</div>
<div className="actions">
<button
className="btn"
disabled={loading}
onClick={() => {
handleDownloadResource(image.secure_url);
}}
>
Download
</button>
<button
className="btn danger"
disabled={loading}
onClick={() => {
handleDelete(image.public_id);
}}
>
Delete
</button>
</div>
</div>
);
})}
</div>
</div>
) : null}
{!loading && images.length === 0 ? (
<div className="no-images">
<b>No Images Yet</b>
<Link href="/">
<a className="btn">Convert some text</a>
</Link>
</div>
) : null}
{loading && images.length === 0 ? (
<div className="loading">
<b>Loading...</b>
</div>
) : null}
<style jsx>{`
div.wrapper {
min-height: 100vh;
background-color: #f4f4f4;
}
div.wrapper div.images-wrapper {
display: flex;
flex-flow: row wrap;
gap: 10px;
padding: 10px;
}
div.wrapper div.images-wrapper div.image-wrapper {
flex: 0 0 400px;
display: flex;
flex-flow: column;
}
div.wrapper div.images-wrapper div.image-wrapper div.image {
background-color: #ffffff;
position: relative;
width: 100%;
}
div.wrapper div.images-wrapper div.image-wrapper div.actions {
background-color: #ffffff;
padding: 10px;
display: flex;
flex-flow: row wrap;
gap: 10px;
}
div.loading,
div.no-images {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
gap: 10px;
}
`}</style>
</Layout>
);
}
Code language: JavaScript (javascript)
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
.
module.exports = {
// ... other options
images: {
domains: ["res.cloudinary.com"],
},
};
Code language: JavaScript (javascript)
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.
npm 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