Introduction
We are not in 2010 anymore, with PHP, JQuery and plain SQL... some things changed.
That's why I will give an overview on one of the most popular and modern technologies to create a web application.
Disclamer: this is one of the milions of ways to build an application. Use this as a blueprint to build modern applications, and as a presentation of modern technologies to implement in your own ways. Copying a full stack entirely is almost always useless, as each app is different and requires different technologies.
If you speak Italian, I posted a video on Datapizza's youtube channel that you can use as an overview of what I am going to talk about in this blog post, as it can be more entertaining, but less technical and in-depth. Watch it here!
The Goal of the Stack
The Goal of this tech stack is to make the entire infrastructure type-safe: from database to front-end, I want to be able to know how the data I have is structured, to make development easier and safer.
Typescript
The language of choice is typescript. For the newer people to programming, typescript extends javascript adding types to the language. It accellerates the development experience, finding errors and providing corrections.
NextJS
NextJS is a full-stack react framework and has many features. My favourites are:
Server Side Rendering
Server Side Rendering (SSR) allows the application to display the web-page on the server rather than rendering it in the browser. When a website’s JavaScript is rendered on the website’s server, a fully rendered html is sent to the client. On the other hand, Single Page Application (for example Plain React, using other bundlers such as vite), send a blank html file, that is rendered on the client with javascript.
The benefits of SSR are:
- Improved Performance: javascript rendering on the browser can become heavy performance-wise, for examle, on mobile or old devices. Rendering on the server, on the other hand, makes sure that the rendering happens with a strong device (the server), and removes that task from the user's device.
- SEO: Since no HTML is sent to the client, single page applications have a very weak Search Engine Optimization. Hence, your website is probably not going to show up in Search Engines.
- Better User Experience: Sending a blank HTML like Single Page Applications do, have the result of showing a white screen, while the javascript is loading. On the other hand, with an already rendered by the server HTML, there is not going to be a white screen on the loading time, which itself is going to be way shorter, due to the better performance of the server.
File Based Routing
There are two types of routers in NextJS:
The app router is the most updated and documented one. I have used both, but as soon as the beta for the App router came out, I started adapting that. It makes SSR way better and has now become the standard.
Based on how the folder structured is, Next is going to create all the routing, both for the api, and for the client, making the developer experience exceptional.
This is an example of the routing of one of my latest projects, The Closer Fit
Caching
NextJS will automatically cache as many requests as possible. This has major benefits:
- Cost: by caching requests, less processing is going to be done on the server => cheaper prices
- Performance: already cached requests do not need to be run again in the server, which means that the users are going to get a result, without running the functions, picking the data from the database...
The NextJS Caching is also highly customizable: you can choose how often the cache is going to be invalidated, and which functions or pages are going to be cached.
More NextJS features
NextJS has many more features, but I will not go in depth in those (check them out in the docs!)
Some of these are:
- Data Fetcing
- Styling
- Image, Script, and Fonts Optimization
- Dynamic HTML Streaming
- More
tRPC
tRPC is an End-to-end typesafe API: it connects the server and the frontend in a typesafe manner. It also does not have a compile step, so it does not have extra code created in between, but simply uses typescript inheritance to determine the types.
tRPC is useful if SSR is not being used, and in non-server components. In server-components, it is better to simply fetch the data from the database, and use that inside the component,and there is no need to move it to the frontend doing an api call:
export default async function ExampleComponent() {
//serverside logic
//note: since it is serverside login this is not going to be run in the browser => we can use env variables, and others
const res = await fetch("https://api.example.com/yourapi")
return (
<div>{res.json()}</div>
)
}
tRPC is also easily integrated with Zod for validation and sanitization. This makes the developer experience not only better and faster, but makes the entire api safer, defining even complecated schemas in a simple manner:
import { z } from "zod"
export const postInterviewSchema = z.object({
type: z.enum(["technical", "behavioural", "mixed"]),
position: z.string().min(1),
description: z.string().min(1).optional(),
})
export const postInterviewAnswerSchema = z.object({
questionId: z.string().min(1),
answerAudio: z.string().min(1),
})
ORM
An ORM is a object-relational mapping. It is a technique used in creating a "bridge" between object-oriented programs and, in most cases, relational databases.
There are many options in a typescript server like ours, but my favourites are Prisma and Drizzle.
They both make you create a schema of the database. And they give you a possibility to perform CRUD operations in an easy and type-safe manner.
Drizzle is objectively the latest and more efficient of the two, but I personally prefer Prisma, simply because I have used it more and know it like the back of my hand.
Defining a model like this...
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
accounts Account[]
sessions Session[]
@@map(name: "users")
}
Can give you the possibility to access the User table from the database like this in a type-safe manner:
const dbUser = await db.user.findFirst({
where: {
email: token.email,
},
select: {
id: true,
name: true,
email: true,
image: true,
resume: true,
},
})
Graphics
TailwindCSS has to be used when it comes to css. It has almost became industry standard to define classes in a string with the pre-defined tailwind values, instead of creating a separate .css file and define all the classes there.
Furthermore, components are almost never built from the ground up anymore. Companies spend milions of dollars to study the UI and UX of components, and we ought not to pretend that we can do better than them. That's why we need to use components libraries. My favourites are:
- Tailwind UI: Beautifully designed, expertly crafted components and templates, built by the makers of Tailwind CSS
- Radix UI: An open source component library optimized for fast development, easy maintenance, and accessibility. Just import and go—no configuration required.
- Material UI: open-source React component library that implements Google's Material Design.
But my favourite is: ShadcnUI From an aestethic standpoint it is on point, but most importantly, it is my favourite in terms of readability and reusability of the code. It wraps around radix-ui components, and makes it very easy to use and accessible components. Here is how easily readable and reusable a code of a dialog buil with ShadcnUI would look like:
<Dialog defaultOpen={hasSearchParam}>
<DialogTrigger asChild>
<Button {...props}>
<Icons.add className="mr-2 size-4" />
New Interview
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Interview</DialogTitle>
<DialogDescription>
Please submit the form below to create a new interview.
</DialogDescription>
</DialogHeader>
{/*
rest of the content here
*/}
<DialogFooter>
<Button type="submit" className="mt-2">
Create Interview
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
Just this, to make it look this good:
Authentication
Handling authentication has never been so hard, and easy at the same time. With passkeys, OAuth, passowrds, reset passwords, encryption... Why would anyone handle all this and build everything out of the box, when you can use something else?
There are two main ways of doing that:
- Using external applications like clerk. This handles everything nicely, but has the downsites of:
- Having to pay a price (even though it is almost free for a small project)
- Not having the authentication on your infrastructure
For these reasons, I prefer an alternative, which gets the benefits of both not having to handle all authentication logic, but also not having pricing and having auth in your infrastructure, allowing you to customize it however you want and have everything in your databases:
- NextAuth: integrated with nextjs effortlessly (yes, it's made by the same team), and allows you to handle authentication this easily:
Git
I don't even think I should mention this, but for the sake of completeness I will.
Version control is necessary. And git and github are kind-of a monopoly in this field.
State Management
useState
State management has always been a discussed an opinionated topic. The most standard way is using the regular useState, but this becomes a very bad approach when data has to be global and accessible in the entire app. In easy applications, though, the only global variables needed would be the user and authentication, but with NextAuth, this would be handled already, without the need of an external state manager.
Redux
On the opposite side of the complexity spectrum, we find redux:
React Query
When state management is only bound to data fetching (when all the data we need the share is fetched), react query allows you to manage the flow and caching of fetch requests, and shares the data globally, and makes it accessible simply from a key.
// It makes the fetching code go from this:
useEffect(() => {
setLoading(true)
fetch(https://api.example/)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false))
}, [])
// To this:
const { data, isLoading, isError } = useQuery({
queryKey: ["posts"], //accessible in the entire application
queryFn: getPosts
})
Zustand
The last state management library that I want to talk about is zustand. It is the easiest and with less boilerplate code way to share state globally, but it can sometimes be even too lightweight to handle more complex state management.
Hosting
Application
Since this is a NextJS application, we have two main options.
My favourite is vercel. Vercel is the company that owns nextJS too. Hence, they have the best integration, and with two buttons, you can connect your github repo to vercel and get the server and front-end hosted in a minute.
An alternative, if for example you have to build the application in an AWS environment, is AWS Amplify, but it is honestly harder to setup and to maintain.
Image hosting
From my experience, I would almost always go to AWS S3 for file and image hosting.
There are many alternatives, but I have found that S3 is easy enough to set up, and if done correctly is probably going to be the cheapest one.
Database
Again, there are multiple ways to host our database.
If you want to go for AWS, again, I would choose a service like AWS RDS, or any other DB hosting service.
Vercel now gives you the possiblity to host your database (and btw, things like images too, as an alternative to S3), thanks to his new service: Vercel Storage. This becomes super useful when you want to stay in vercel and host everything on a single provider, and most importantly, when you are using NextJS.
Other modern solutions are also available, such as Subase
Wrapping up
Here is a diagram that summarizes most of the tech stack we built today:
Use this Tech Stack and technologies as a blueprint, and get inspiration from this to create your own go-to tech stack.