Process

Setting up & Project Structure

Next.js makes bootstrapping a new project very easy using their create-next-app utility. Generally, I used the default settings that the utility suggested. Importantly, this included using TypeScript, Tailwind CSS for styling, and the app router. The latter, which replaces the original Next.js pages router, enables React’s newest features, such as server components.

Although Next.js doesn’t prescribe a project structure, their default examples put everything into the app directory that is used for defining page routes. As I prefer to keep things a bit more separated, I opted for enabling the src directory during bootstrapping. Within this directory, I created several folders for different elements:

  • actions: server-side actions that are called from the client to manipulate things on the server.
  • app: used by the app router to define pages and layouts.
  • components: re-usable React components that can be included in pages
  • db: methods for accessing data in the database
  • files: methods for accessing and manipulating files on the file system
  • utils: utility functions for use in other methods

Where useful, I created sub-directories to categorise files.

Styling and Components

As it was the default option and a framework I’d been interested in for a while, I used Tailwind CSS1 for styling the application. This library provides utility classes that can be used to control the CSS styling of an element. An upside of this method is that the styles are directly in the code, which makes it very easy to make changes and apply new styles to an element. On the other hand, having to use many classes can also make the code feel bloated, and makes it more difficult to re-use styles.

Luckily, React is built around components, functions that contain layout, styling and code, and which can be re-used as often as one likes. To keep the code organised, I created components for often-used design elements, such as buttons and input fields, but also for larger items, such as photo lists and different types of dialogs.

Besides creating my own, as a shortcut, I used some off-the-shelf components for more complex elements, such as drop-down menus and the upload box. For these, I chose libraries (mostly Radix Primitives2) that did not provide any styling themselves, so that their design could be kept in line with the rest of the interface.

Database

There is a plethora of libraries available for connecting to a database, ranging from bare-bones to having a lot of features. For this project, I used Prisma3, a library belonging to the latter category. Prisma is an ORM that allows the developer to define the data model in a schema file, and then generates types and methods for accessing the data. In addition, it can apply the schema to the database itself and handle data migrations to ensure the data in the database has the same format.

These functionalities make it very easy to interact with the data and make changes to the schema without having to worry much about updating the database. There are, however, also downsides: Prisma is heavier and has more overhead compared to leaner options, leading to a bit lower performance4. Originally, I considered swapping it out for a more barebones option when the database model stabilised, but due to time constraints, and as I have not had any problems with Prisma, this is off the table.

File Handling

The application needs to be able to handle lots of image files. To give the end-user the ability to decide where their images are stored, I made the image directories (per type of image) configurable. Within these image directories, images are named by their unique identifier, which ensures there are no collisions and they are easy to look up without having to find a file name. The downside is that the image files are not recognisable by their name; however, that is not an intended use-case anyway.

Next.js provides a component for optimising images on-the-go (when requesting them) or during build time. However, its options did not entirely fit the use-case of the photo gallery. Instead, I decided to generate thumbnails and previews of the images during the upload process, which ensures they are always available when the image is in the database. The conversion library, sharp5, is the same one used internally by Next.js, and fast enough that the time added to the uploading process is generally hardly noticable. I did use the image-component for its other features, such as lazy-loading the images.

Data Fetching and Mutation

Server components are an essential part of the Next.js app router. These components are rendered on the server, and therefore have access to backend resources such as the database and filesystem. I used this method throughout the application to render dynamic data. Where interactivity is needed, the data can be passed seamlessly to client components that are hydrated on the client to allow their code to run.

To allow the user to perform actions on the backend, such as server mutations, Next.js provides server actions, which are also an integral part of the app router. These can be invoked from forms and client components, after which they run on the server and return their result. Due to their simplicity, these are generally what I used for any data mutations, although I had to rely on a more traditional POST request for uploading new files. On the server side, I used Zod6 for validating incoming data from the client and provide meaningful error messages.