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 integrity, run complex business logic through synchronous and asynchronous services, manage user sessions, schedule 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


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.

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:

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.
.png)
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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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.

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);
}
}

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.