Skip to main content

How to Build a Pixel Art Creator with NextJS and Tailwind CSS

· 16 min read
Evren Vural

Welcome to the world of pixel art! In this tutorial, we will be building a pixel art creator using NextJS and Tailwind CSS. By the end of this tutorial, you will have a solid understanding of how to create a responsive, user-friendly pixel art creator that allows users to create and save their own pixel art creations. We will be using NextJS for the backend, providing easy server-side rendering and dynamic routing, and Tailwind CSS for styling and layout, making it easy to create a consistent and responsive design. So, let's get started and build something fun and creative together!

What is Pixel Art?

Pixel art is a form of digital art where images are created and edited at the pixel level. It typically features low resolution and a limited color palette and is often used to create retro-style graphics in video games and other digital media. The style is characterized by its blocky, pixelated appearance and is created using software tools designed explicitly for pixel art.

What is Altogic?

Altogic backend as a service platform allows you to create and start running your backend apps in minutes. With your backend apps, you can manage your application data in a database, cache your data in memory, execute database transactions to ensure data integrityrun complex business logic through synchronous and asynchronous services, manage user sessionsschedule jobs to be executed at a specific time or interval, send and receive real-time messages through WebSockets and more.

Creating an Altogic Application

As you are aware, Altogic will be used in this project, so if you do not already have an account, you must create one from the Altogic Designer.

I recommend you to review the documentation for more in-depth details.

Setting Up Your Development Environment

npx [email protected]
# or
yarn create next-app

Installing Altogic Client Library

The Altogic client library is required for our front-end application because the backend will use Altogic. To install the Altogic client library, run the following command in your terminal:

npm install altogic
# or
yarn add altogic

Creating Altogic instance

Let’s create a configs/ folder to add the altogic.js file. Open altogic.js and paste below code block to export the altogic client instance.

import { createClient } from "altogic";

let envUrl = ''; // replace with your envUrl
let clientKey = ''; // replace with your clientKey

const altogic = createClient(envUrl, clientKey, {
signInRedirect: "/sign-in",
});

export const { db, auth, storage, endpoint, queue, realtime, cache } = altogic;

Replace envUrl and clientKey which are shown in the Home view of Altogic Designer.

App preview and screens

App preview
App preview

Creating a backend for your Pixel Art App with Altogic

Models

In order to build our pixel art creator, we will first need to create our models. We will have four main models in our application: the user model, the pixel_arts model, the pixel_user_connections model, and the invitation model.

The user model will contain information about the users of our application, such as their name and email address. The pixel_arts model will contain information about the individual pixel art creations, such as the name, slug, and image data.

There is a many-to-many relationship between the pixel_arts model and the user model, meaning that one user can create multiple pixel art creations and one pixel art creation can have multiple creators. To handle this relationship, we have created the pixel_user_connections model. This model will be used to connect the pixel_arts and user models, allowing us to easily retrieve the creators of a specific pixel art creation or the pixel art creations created by a specific user.

To optimize performance, we have added some fields to the pixel_user_connections model to minimize the number of lookups needed during GET queries. This will help to reduce the amount of processing required and make our application more efficient.

Database diagram and model relations

Services

With the client library in Altogic, we can perform a wide range of operations. Still, occasionally we may need to handle multiple tasks at the same time, or there may be circumstances where additional security is necessary. In these situations, we can write a service using drag and drop in Altogic.

Here is a list of services that we will be using in our application:

Service list

Create Pixel Art Service

It’s used to create pixel art. It creates a pixel_arts object and a pixel_user_connections object.

LOGIC

A pixel_arts object is created and saved in the database. Then a pixel_user_connections object is created, and The pixel_arts object is returned as a response.

App preview

Update Pixel Name Service

It’s used to update the pixel’s name. Data in pixel_arts and pixel_user_connections are updated.

LOGIC

The name field in pixel_art is updated with the query according to pixelSlug. Then the pixelName in pixel_user_connections is updated.

update pixel name service.png

Remove Pixel Art Service

It’s used to remove pixel art. All connection and pixel art records are removed.

LOGIC

All pixel_user_connections objects are removed with the query according to pixelSlug. Then the pixel_art object is removed.

remove pixel art service.png

Replace Pixel Picture Service

It's used to update pixel pictures. We need pixel art pictures to show in the OG image. This required photo is recorded for storage with this service before sharing.

LOGIC

The png file of the drawn pixel art is replaced with the one in the storage.

remove pixel art service.png

Update Pixel Picture Service

It’s used to update pixel pictures’ paths in pixel_arts and pixel_user_connections models.

LOGIC

After saving the pixel picture to storage, this service is called. The publicPath in the body is saved in the picture field in the pixel_arts model. Then the pixelPicture in the pixel_user_connections model is updated.

remove pixel art service.png

Send Invite Service

It's used to send invite people. Service first checks if the invited user is a member of pixel art. If the user is a member, it throws an error. Then it is checked whether the invited user has been invited before. If invited, the error is thrown. After passing all these checks, the invitations model is recorded, and an e-mail is sent to the invited user.

remove pixel art service.png

Join Pixel Art Service

Invited users join the team through this service. First, it is checked whether there is such an invitation and whether it has been a member. After the checks are made, the connection model is recorded and deleted from the invitation table.

remove pixel art service.png

Delete Member Service

It’s used to remove members from pixel art.

LOGIC

The pixel_user_connections objects is removed with the query according to pixelSlug and userId. Then the drawerSize field of the pixel art model is decreased by one.

remove pixel art service.png

Get Role Service

It is the service that defines the member's role in pixel art. Create variable object creates role variable. The pixel_user_connections table checks to find the connection of the user who requested the pixel art.

remove pixel art service.png

Change Profile Picture Service

It is the service where the user changes his profile photo.

After saving the user picture to storage, this service is called. The "publicPath" in the body is saved in the profilePicture field in the “users” model. Then the “userProfilePicture” in the “pixel_user_connections” model is updated.

remove pixel art service.png

Change User Name Service

It is the service that the user renames.

LOGIC

The name field in users is updated with the query according to userId. Then the userName in pixel_user_connections is updated.

remove pixel art service.png

Integrating Your App’s Frontend with the Backend

Displaying a List of Canvas

Data is kept in matrix form according to the specified size. An element of the matrix; stores the information of its location with its x and y coordinates and the data of its color with its color field.

remove pixel art service.png

It is drawn to the screen with two loops.

import cs from "classnames";
import _ from "lodash";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { pixelActions } from "../redux/pixel/pixelSlice";
import { realtimeActions } from "../redux/realtime/realtimeSlice";
import Cell from "./cell";

export default function PixelTable({ drawColor, canDraw, size }) {
const router = useRouter();
const { pixelSlug } = router.query;
const pixelRef = useRef(null);

const dispatch = useDispatch();
const data = useSelector((state) => state.pixel.pixel);

const [holdDown, setHoldDown] = useState(false);

const handleFocus = (e) => {
if (!document.getElementById("pixel-table")?.contains(e.target)) {
// Clicked outside the box
setHoldDown(false);
}
};

useEffect(() => {
dispatch(
realtimeActions.joinRequest({
pixelSlug,
})
);

return () => {
dispatch(
realtimeActions.leaveRequest({
pixelSlug,
})
);
};
}, [pixelSlug]);

useEffect(() => {
if (pixelRef?.current) {
pixelRef.current.addEventListener("mouseup", holdUpMouse);
pixelRef.current.addEventListener("mousedown", holdDownMouse);
window.addEventListener("click", handleFocus, false);
}

return () => {
if (pixelRef?.current) {
window.removeEventListener("click", handleFocus, false);
pixelRef.current.removeEventListener("mouseup", holdUpMouse);
pixelRef.current.removeEventListener("mousedown", holdDownMouse);
}
};
}, [pixelRef]);

const holdDownMouse = () => {
setHoldDown(true);
};
const holdUpMouse = () => {
setHoldDown(false);
};

const draw = (x, y) => {
if (!canDraw || !data[y] || !data[y][x] || data[y][x].color === drawColor) {
return;
}

dispatch(pixelActions.drawPixel({ x, y, drawColor }));
dispatch(
realtimeActions.drawRequest({
pixelSlug,
x,
y,
drawColor,
})
);
dispatch(
pixelActions.savePixelRequest({
slug: pixelSlug,
})
);
};

const onMouseDown = ({ x, y }) => {
draw(Number(x), Number(y));
};

const onMouseOver = ({ x, y }) => {
if (holdDown) {
draw(x, y);
}
};

return (
<div
ref={pixelRef}
id="pixel-table"
className={cs([
"my-3 2xl:my-6",
canDraw
? "touch-none touch-pinch-zoom cursor-cell"
: "cursor-not-allowed",
])}
>
{_.map(data, (row, rowIndex) => (
<div key={rowIndex} className="flex w-full">
{_.map(row, (column, columnIndex) => (
<div key={`${rowIndex},${columnIndex}`}>
<Cell
size={size}
indexKey={`${rowIndex},${columnIndex}`}
data={column}
onMouseDown={onMouseDown}
onMouseOver={onMouseOver}
/>
</div>
))}
</div>
))}
</div>
);
}
import cs from "classnames";

export default function Cell(props) {
const { indexKey, data, onMouseDown, onMouseOver, size } = props;

const handleTouchMove = (ev) => {
const touchX = ev.touches[0].pageX - window.pageXOffset;
const touchY = ev.touches[0].pageY - window.pageYOffset;

const element = document.elementFromPoint(touchX, touchY);

if (element) {
const [y, x] = element.id.split(",");
onMouseDown({ x, y }, ev);
}
};
return (
<div
id={indexKey}
style={{
backgroundColor: data?.color,
}}
className={cs([
"border border-gray-100",
size === 16 &&
"w-5 h-5 sm:w-10 sm:h-10 md:w-12 md:h-12 lg:w-14 lg:h-14",
size === 32 && "w-2.5 h-2.5 sm:w-5 sm:h-5 md:w-6 md:h-6 lg:w-7 lg:h-7",
size === 48 &&
"w-[0.42rem] h-[0.42rem] sm:w-[0.83rem] sm:h-[0.83rem] md:w-4 md:h-4 lg:w-[1.16rem] lg:h-[1.16rem]",
])}
onMouseDown={(ev) => onMouseDown(data, ev)}
onMouseOver={(ev) => onMouseOver(data, ev)}
onFocus={(ev) => onMouseOver(data, ev)}
onTouchMove={handleTouchMove}
/>
);
}

Adding Realtime Functionality

A request for real-time connection is thrown when the page is loaded

useEffect(() => {
dispatch(
realtimeActions.joinRequest({
pixelSlug,
})
);

return () => {
dispatch(
realtimeActions.leaveRequest({
pixelSlug,
})
);
};
}, [pixelSlug]);

We wrap the realtime services we will use with the realtimeService object.

import { realtime } from "../../configs/altogic";

const realtimeService = {
join: (channel) => realtime.join(channel),
leave: (channel) => realtime.leave(channel),
removeListen: (eventType) => realtime.off(eventType),
listen: (eventType, callback) => realtime.on(eventType, callback),
sendMessage: (channel, event, message) =>
realtime.send(channel, event, message),

updateProfile: (user) => realtime.updateProfile(user),
getMembers: (channel) => realtime.getMembers(channel),
};

export default realtimeService;

While the states are being updated, if a request comes in, this request is canceled. The channels of the redux-saga are used to prevent pixel loss in this situation.

import _ from "lodash";
import { eventChannel } from "redux-saga";
import {
all,
apply,
call,
fork,
put,
select,
take,
takeEvery,
takeLatest,
} from "redux-saga/effects";
import { ArtEventType } from "../../functions/constants";
import pixelService from "../pixel/pixelService";
import { pixelActions } from "../pixel/pixelSlice";
import realtimeService from "./realtimeService";
import { realtimeActions } from "./realtimeSlice";

function* drawSaga({ payload: { pixelSlug, x, y, drawColor } }) {
const sent = yield select((state) => state.realtime.realtimeKey);
yield apply(realtimeService, realtimeService.sendMessage, [
pixelSlug,
ArtEventType.DRAW,
{ data: { x, y, drawColor }, sent },
]);
}

function* listenSocket(socketChannel) {
while (true) {
try {
const { data, sent, type } = yield take(socketChannel);

switch (type) {
case "join":
const { id, data: member } = data;
yield put(
pixelActions.updateMembers({
key: id,
value: { id, ...member },
})
);
break;
case "leave":
yield put(
pixelActions.removeMembers({
key: data.id,
})
);
break;
case ArtEventType.DELETED:
window.location.href = "/";
break;
case ArtEventType.UPDATED_NAME:
const pixelConn = yield select((state) =>
_.get(state.pixel.pixelConnections, data.data.pixelSlug)
);
const pixel = yield select((state) =>
_.get(state.pixel.pixels, data.data.pixelSlug)
);
yield put(
pixelActions.updatePixelConnections({
key: data.data.pixelSlug,
value: {
...pixelConn,
pixelName: data.data.name,
pixel: {
...pixel,
name: data.data.name,
},
},
})
);
yield put(
pixelActions.updatePixels({
key: data.data.pixelSlug,
value: {
...pixel,
name: data.data.name,
},
})
);
break;
case ArtEventType.REMOVE_MEMBER:
const user = yield select((state) => state.auth.user);
if (data.data === user?._id) {
realtimeService.updateProfile({
userId: user._id,
name: user.name,
profilePicture: user.profilePicture,
slug: user.slug,
role: "viewer",
});
}
break;

default:
const realtimeKey = yield select(
(state) => state.realtime.realtimeKey
);
if (sent !== realtimeKey) {
yield put(pixelActions.drawPixel(data));
}
break;
}
} catch (err) {
console.error("socket error:", err);
}
}
}

function* joinSaga({ payload: { pixelSlug } }) {
realtimeService.join(pixelSlug);
yield put(realtimeActions.setPixelSlug(pixelSlug));

const socketChannel = yield call(createSocketChannel);
yield fork(listenSocket, socketChannel);
yield fork(leaveSaga, socketChannel);
yield fork(joinMemberSaga, pixelSlug);
}

function* joinMemberSaga(pixelSlug) {
try {
const user = yield select((state) => state.auth.user);
if (user) {
const {
data: { role },
errors,
} = yield call(pixelService.getRole, pixelSlug);
if (errors) {
throw errors;
}

realtimeService.updateProfile({
userId: user._id,
name: user.name,
profilePicture: user.profilePicture,
slug: user.slug,
role,
});
}
} catch (e) {
console.error(e);
}
}

function* leaveSaga(socketChannel) {
while (true) {
const {
payload: { pixelSlug },
} = yield take(realtimeActions.leaveRequest.type);
realtimeService.leave(pixelSlug);
yield put(realtimeActions.setPixelSlug(null));
socketChannel.close();
}
}

function createSocketChannel() {
return eventChannel((emit) => {
const drawHandler = (event) => {
emit(event.message);
};
const joinHandler = (event) => {
emit({ type: "join", data: event.message });
};
const leaveHandler = (event) => {
emit({ type: "leave", data: event.message });
};
const deleteHandler = (event) => {
emit({ type: ArtEventType.DELETED, data: event.message });
};
const updatedNameHandler = (event) => {
emit({ type: ArtEventType.UPDATED_NAME, data: event.message });
};
const removeMemberHandler = (event) => {
emit({ type: ArtEventType.REMOVE_MEMBER, data: event.message });
};

// setup the subscription
realtimeService.listen(ArtEventType.DRAW, drawHandler);
realtimeService.listen("channel:join", joinHandler);
realtimeService.listen("channel:update", joinHandler);
realtimeService.listen("channel:leave", leaveHandler);
realtimeService.listen(ArtEventType.DELETED, deleteHandler);
realtimeService.listen(ArtEventType.UPDATED_NAME, updatedNameHandler);
realtimeService.listen(ArtEventType.REMOVE_MEMBER, removeMemberHandler);

const unsubscribe = () => {
realtimeService.removeListen(ArtEventType.DRAW);
realtimeService.removeListen("channel:join");
realtimeService.removeListen("channel:update");
realtimeService.removeListen("channel:leave");
realtimeService.removeListen(ArtEventType.DELETED);
realtimeService.removeListen(ArtEventType.UPDATED_NAME);
realtimeService.removeListen(ArtEventType.REMOVE_MEMBER);
};

return unsubscribe;
});
}

function* getMembersSaga({ payload: { pixelSlug } }) {
try {
const { data, errors } = yield call(realtimeService.getMembers, pixelSlug);
if (errors) {
throw errors;
}
let newMembers = {};

if (!_.isEmpty(data)) {
for (const { id, data: member } of data) {
newMembers[id] = { id, ...member };
}
yield put(pixelActions.setMembers(newMembers));
}
} catch (e) {
console.error(e);
}
}

export default function* realtimeSaga() {
yield all([
takeEvery(realtimeActions.joinRequest.type, joinSaga),
takeEvery(realtimeActions.drawRequest.type, drawSaga),
takeLatest(realtimeActions.getMembersRequest.type, getMembersSaga),
]);
}

Saving canvas to SVG

With the help of the html2canvas library, the selected element is converted to blob type and downloaded.

const download = () => {
html2canvas(document.querySelector("#pixel-table")).then((canvas) => {
canvas.toBlob(function (blob) {
saveCanvasToDisk(blob, "png");
});
});
};

const saveCanvasToDisk = (blob, fileExtension) => {
saveAs(blob, `${pixel?.name}.${fileExtension}`);
};

Adding Additional Functionality

Securing Your App with Object Level Security

With Altogic Object Level Security, you can add restrictions to crud operations on models. In this way, outside interference with the application is prevented.

remove pixel art service.png

Securing your app with Rate Limiter

With Rate Limiter, you can prevent possible DDOS attacks on your website. This project can receive 250 requests within 15 seconds. If it gets more requests, it will return an error.

remove pixel art service.png

OG Image

OG are the images that appear when you share the application. For the painting to appear, you need to add it to the meta tag in the HTML. I wanted each pixel art to have its image in this application. I used the SSR technology of next.js to achieve this.

remove pixel art service.png

I used Next.Js @vercel/og package to provide this image and created og-images.js in pages/api folder.

import { ImageResponse } from "@vercel/og";

export const config = {
runtime: "experimental-edge",
};

export default async function handler(req) {
const { searchParams } = new URL(req.url || "");
const link = searchParams.get("link");

if (!link) {
return new Response("Missing link", { status: 400 });
}

return new ImageResponse(
(
<section
style={{ backgroundColor: "#4c1d95" }}
tw="flex flex-col items-center justify-center w-full h-full"
>
<div tw="flex my-2">
<img width={600} height={600} src={link} alt="" />
</div>
</section>
),
{
width: 1200,
height: 627,
headers: {
"Cache-Control": "no-cache",
},
}
);
}

After pressing the share button, we send a request to our replace picture service before sharing. After saving the final version of Pixel Art, we send it as a link to the API we created.

const beforeOnClick = () =>
new Promise((resolve) => {
setLoading(true);
dispatch(
pixelActions.replacePictureRequest({
pixelSlug,
onSuccess: () => {
setLoading(false);
resolve();
},
onFailure: () => {
setLoading(false);
resolve();
},
})
);
});

Let's define the api link we created to the meta tags.

                <meta
property="twitter:image"
content={`https://pixel-art-next.vercel.app/api/og-image?link=${
pixel?.picture
}&name=${pixel?.name}&time=${new Date().getTime()}`}
/>
<meta
property="og:image"
content={`https://pixel-art-next.vercel.app/api/og-image?link=${
pixel?.picture
}&name=${pixel?.name}&time=${new Date().getTime()}`}
/>

Optimizing your Pixel Art app’s performance

It is not the right approach to send a request after each frame is drawn. We should minimize network requests so I used debounce.

const draw = (x, y) => {
if (!canDraw || !data[y] || !data[y][x] || data[y][x].color === drawColor) {
return;
}

dispatch(pixelActions.drawPixel({ x, y, drawColor }));
dispatch(
realtimeActions.drawRequest({
pixelSlug,
x,
y,
drawColor,
})
);
dispatch(
pixelActions.savePixelRequest({
slug: pixelSlug,
})
);
};

“savePixelRequest” is handled in redux-saga with debounce.

remove pixel art service.png
function* savePixelSaga({ payload: { slug, onSuccess, onFailure } }) {
try {
const pixelPallette = yield select((state) => state.pixel.pixel);

const { errors } = yield call(
pixelService.draw,
slug,
JSON.stringify(pixelPallette)
);
if (errors) {
throw errors;
}

if (_.isFunction(onSuccess)) onSuccess();
} catch (e) {
if (_.isFunction(onFailure)) onFailure(e);
}
}
remove pixel art service.png

Conclusion

In this article, we have created a pixel art application with Altogic. We have learned how to use Altogic’s services and how to create a pixel art application with Altogic. We have also learned how to use OG Image in our application and how to optimize our application’s performance.

Source code: https://github.com/altogic/pixel-art

Live Demo: https://pixela-eta.vercel.app/

If you have any questions about Altogic or want to share what you have built, please post a message in our community forum or discord channel.