Photo by AurΓ©lien Dockwiller on Unsplash
Simplest Next.js setup to build full stack web applications with speed
Guide to setting up a full-stack Next.js application with Docker, Prisma, and PostgreSQL for rapid development
I have been in the React ecosystem for around 2 years now, Till now I have tried n number of ways and used different tech stacks to develop full stack web applications with React. With all this experience I have found what I believe to be the simplest and fastest way to set up and build a full-stack web application with React. With this guide I will walk you through the processπΆ.
Prerequisites
Before we begin, ensure you have the following tools installed:
Docker: Docker allows you to build, test, and deploy applications quickly by packaging software into standardized units called containers. These containers include everything the software needs to run, such as libraries, system tools, code, and runtime. To get started with Docker, follow the official guide.
Node.js: This is a JavaScript runtime which enables you to run JavaScript outside of a browser. It is important for our development environment. You can learn more and follow the instructions here from official Node.js website.
Git: It is a version control system, it is invaluable for any developer. If you haven't installed it yet, follow the Git installation guide.
Teck Stack
We'll be using the following technologies:
Next.js: Next.js is a React framework, in 2024 it is my favorite way to use react, it provides many out of the box features like, file based routing, easy to use middleware, server actions, etc. These features would take a lot of time to setup without Next.js.
Prisma ORM: An Object-Relational Mapping (ORM) tool that provides a simple syntax to interact with databases.
PostgreSQL: We will use it as our relational database system.
Docker: We will use Docker to run a PostgreSQL image for local development.
Setting up Next.js application
Let's start by creating a new Next.js application:
Open your terminal and navigate to the directory where you want to create your project.
Create a new directory for your project:
mkdir <your-app-name>
Open the new directory in VS Code:
code <your-app-name>
In the VS Code terminal, run:
npx create-next-app@latest
This will start an interactive session where you can define the initial setup for your application. Here are the recommended options:
Here are the recommended options to select:
Project name: Enter your project name or use
.
to set up in the current folder.TypeScript: Yes (provides better autocomplete and type checking)
ESLint: Yes (helps detect problems and bugs in your code)
Tailwind CSS: Yes (makes writing CSS easier in Next.js applications)
src/
directory: Yes (helps structure your project for easier navigation)App Router: Yes (takes advantage of Next.js 13+ features)
Import alias: No (keep the default
@
alias)
After this setup is completed, your project structure should look like this:
.
βββ README.md
βββ next-env.d.ts
βββ next.config.mjs
βββ package-lock.json
βββ package.json
βββ postcss.config.mjs
βββ public
β βββ next.svg
β βββ vercel.svg
βββ src
β βββ app
β βββ favicon.ico
β βββ globals.css
β βββ layout.tsx
β βββ page.tsx
βββ tailwind.config.ts
βββ tsconfig.json
You can now run npm run dev
to start the Next.js development server, typically at http://localhost:3000
.
Setting Up ShadCN UI (Optional)
ShadCN UI provides a collection of reusable React components. To install it:
Run
npx shadcn-ui@latest init
Choose the default options during the interactive setup
This will create a components.json
file and two new folders:
components
: Contains the code for UI componentslib
: Contains utility functions components and lib.
To add a new component, use the ShadCN CLI. For example, to add a button component:
npx shadcn-ui@latest add button
This will create a button.tsx
file in /components/ui
.
You can then use these components in your application like this:
import { Button } from "@/components/ui/button";
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
The best full-stack setup.
<Button>
Click Me
</Button>
</main>
);
}
We can add more components from here.
Running Postgres locally
For local development, it's often better to use a local database instead of using a hosted database on a cloud-based Database-as-a-Service (DBaaS) platfrom. This approach is cost-free and doesn't require an internet connection.
We will use Docker to run a PostgreSQL image locally. First, ensure Docker is running on your computer by typing docker
in your terminal.
Create a docker-compose.yml
file in your project root with the following content:
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_PASSWORD: development
ports:
- '5432:5432'
This the most minimal configuration to run the Postgres
image, there are other options as well but those are optional and if not specified are set to default.
Let's briefly discuss our configuration:
Uses the
postgres:15-alpine
image (a lightweight version of PostgreSQL 15)Sets the database password to "development"
Exposes PostgreSQL's default port (5432) to your local machine
To start the PostgreSQL container, run:
docker compose up
Once it's running, you can access PostgreSQL at localhost:5432
.
Connecting Prisma ORM
To set up Prisma ORM:
Install Prisma as a development dependency:
npm install prisma --save-dev
Initialize Prisma:
npx prisma init
In your
.env
file, add the database connection URL:# The structure `postgres://<db-username>:<password>@<host>:<port-number>/<db-name> POSTGRES_URL="postgres://postgres:development@localhost:5432/dev-db"
In
schema.prisma
, ensure the database connection uses the same environment variable:datasource db { provider = "postgresql" url = env("POSTGRES_URL") }
Define a sample schema in
schema.prisma
:model Post { id Int @id @default(autoincrement()) text String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Generate the database schema:
npx prisma migrate dev
This command updates the database and creates a migrations
folder in the prisma
directory.
You can use Prisma Studio with the following command:
npx prisma studio
It will open up an window in your browser.
It helps to interact with our database visually even without creating the CRUD functionality ourselves.
Creating a Prisma Client
When working with Prisma in a Next.js application, we need to be aware of a potential issue related to hot reloading during development. Let's look little bit closer into this problem and its solution.
Understanding the Hot Reload Problem
During development, Next.js uses a feature called hot reloading to automatically update your application in the browser as you make changes to your code. This feature greatly improves the development experience, but it can cause issues with Prisma Client.
Here's what happens:
Every time you make a change to your code, Next.js reloads the affected modules.
This reload can trigger the creation of a new Prisma Client instance.
Each Prisma Client instance opens its own connection pool to the database.
These connection pools are not automatically closed when a new instance is created.
As a result, if you make frequent changes to your code during development, you might end up with many open connection pools, potentially exceeding your database's connection limit.
For example:
Let's say each Prisma Client instance opens a pool with 10 connections.
If you make changes that trigger a reload 5 times, you could end up with 50 open connections (5 instances * 10 connections each).
This can quickly escalate, especially in larger projects with frequent updates.
This is not an issue in production because the application doesn't use hot reloading there. However, during development, it can lead to crashing your application if you exceed the database's connection limit.
The Solution: Singleton Pattern
To solve this issue, we'll implement a singleton pattern for the Prisma Client. This will ensure that only one instance of the client is created and reused throughout the application lifecycle, even during hot reloads.
Create a file src/utils/db.ts
with the following content:
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
declare global {
var prisma: PrismaClient | undefined;
}
if (process.env.NODE_ENV === 'production') {
prisma = new PrismaClient();
} else {
if (!global.prisma) {
global.prisma = new PrismaClient();
}
prisma = global.prisma;
}
export default prisma;
Let's break down how this solution works:
We declare a
prisma
variable to hold our Prisma Client instance.We use
declare global
to add theprisma
property to the global namespace. This allows us to store the Prisma Client instance in a way that persists across hot reloads.In production, we simply create a new Prisma Client instance as normal.
In development:
We check if
global.prisma
already exists.If it doesn't, we create a new Prisma Client instance and assign it to
global.prisma
.If it does exist, we reuse the existing instance.
Finally, we assign the Prisma Client instance (either the existing one or the newly created one) to our
prisma
variable and export it.
By using this singleton pattern, you can safely use Prisma in your Next.js application. Simply import the prisma
instance from src/utils/db.ts
whenever you need to perform database operations in your application.
Adding CRUD Functionality with Next.js and Prisma (Optional)
In this section, we will go through and create a very basic application with CRUD (Create, Read, Update, Delete) functionality to see how easily we can use the new server actions and Prisma together. If you are already aware of how it works, feel free to skip this section.
Implementing CRUD Operations
Let's implement Create, Read, Update, and Delete operations for our posts.
Creating the Posts Model
I assume you have a Post model from the last section defined in your schema.prisma
. It should look something like this:
model Post {
id Int @id @default(autoincrement())
text String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Creating the Server Actions
Create a new folder called actions
inside the src
folder and add a new file called posts.ts
. At the top of the file, add the "use server" directive:
"use server"
import prisma from "@/lib/db";
import { revalidatePath } from "next/cache";
export type State = {
message?: string | null;
}
This directive tells Next.js that the code in this file should only run on the server.
Implementing Create Functionality
Let's create a server action called addPosts
:
export async function addPosts(prevState: State, formData: FormData) {
const text = formData.get("text") as string;
if (text) {
await prisma.post.create({
data: { text }
});
revalidatePath("/");
return { message: "success" };
} else {
return { message: "Invalid field." };
}
}
This function:
Extracts the "text" field from the form data.
If text is provided, it creates a new post in the database.
Calls
revalidatePath("/")
to clear the cache for the home page, ensuring we see the new post immediately.Returns a success or error message.
Now, let's create a form component to add new posts. Create a new file src/components/posts-form.tsx
:
"use client"
import { addPosts, State } from "@/actions/posts"
import { useFormState } from "react-dom"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
export default function PostsForm() {
const initialState: State = {
message: null
}
const [state, dispatch] = useFormState(addPosts, initialState)
return (
<form action={dispatch} className="w-full flex flex-col gap-4">
{state.message && (
<div>{state.message}</div>
)}
<div className="flex gap-2">
<Input
name="text"
type="string"
placeholder="Add some text here."
/>
<Button>Submit</Button>
</div>
</form>
)
}
This component uses the useFormState
hook, which is a important part of how we're handling form submissions with server actions. Let's break down how useFormState
works:
useFormState
is a React hook provided by thereact-dom
package. It's designed to work with server actions in Next.js applications. (will be changed in newer version: react.dev/reference/react/useActionState)The hook takes three arguments (one is optional, and not important in our context):
The first argument is the server action function (
addPosts
in this case).The second argument is the initial state (
initialState
in this case).
The hook returns an array with two elements:
state
: This is the current state of the form. It includes any messages or data returned from the server action.formAction
(we named in dispatch): This is a function that we use as the form's action. It handles submitting the form data to the server action.
When the form is submitted:
The
dispatch
function is called with the form data.This triggers the server action (
addPosts
).The server action processes the data and returns a new state.
React updates the
state
with this new information.
In our component, we're using the
state.message
to display any messages returned from the server action. This could be a success message or an error message.
This pattern is repeated in our update functionality as well, providing a consistent way of handling form submissions throughout our application.
Let use add this PostForm
component to our main page.tsx
:
import PostsForm from "@/components/posts-form";
export default async function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<PostsForm />
</main >
);
}
Implementing Read Functionality
We'll implement the read functionality in our main page component. Update the src/app/page.tsx
file:
import PostsForm from "@/components/posts-form";
import prisma from "@/lib/db";
import { Post } from "@prisma/client";
export default async function Home() {
const posts: Post[] = await prisma.post.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="w-full flex flex-col gap-2">
{posts.map((post) => (
<div key={post.id} className="p-3 flex justify-between items-center gap-2 border-2 border-black">
{post.text}
</div>
))}
</div>
<PostsForm />
</main>
);
}
Here we are using React Server Components (in Nextjs13+ every component is a server component unless specifically defined as client component by using "use client" directive at the top of the file. We can use async/await
directly in this components to fetch data and the component will be rendered on the server with the fetched data and the client will receive the rendered HTML, providing faster initial load speed.
This component fetches all posts from the database, displays them in reverse chronological order, and includes the PostsForm
component for adding new posts.
Implementing Delete Functionality
Let's add the delete functionality. First, add a deletePost
function to your posts.ts
file:
export async function deletePost(postId: number, formData: FormData) {
try {
await prisma.post.delete({
where: { id: postId }
});
revalidatePath("/");
return { message: "Post deleted successfully" };
} catch (err) {
console.error(err);
return { message: "Error deleting post" };
}
}
Now, create a new component for the delete button. Create a file delete-post.tsx
in your components folder:
"use client";
import { deletePost, State } from "@/actions/posts";
import { Button } from "@/components/ui/button";
import { TrashIcon } from "lucide-react";
interface DeletePostProps {
postId: number;
}
export default function DeletePost({ postId }: DeletePostProps) {
const deletePostWithId = deletePost.bind(null, postId);
return (
<form action={deletePostWithId} className="flex">
<Button>
<span className="sr-only">Delete</span>
<TrashIcon className="w-5 h-5" />
</Button>
</form>
);
}
Here, we use the bind
function to create a new function deletePostWithId
. The bind
function allows us to pre-set the first argument of the deletePost
function (the postId
) to a specific value. This is useful because the action
attribute of the form expects a function that only takes FormData
as an argument, but our deletePost
function needs both postId
and FormData
. By using bind
, we create a new function that already has the postId
"baked in", so it only needs to receive the FormData
when the form is submitted.
Update your Home
component to include the delete button:
import DeletePost from "@/components/delete-post";
import PostsForm from "@/components/posts-form";
import prisma from "@/lib/db";
import { Post } from "@prisma/client";
export default async function Home() {
const posts: Post[] = await prisma.post.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="w-full flex flex-col gap-2">
{posts.map((post) => (
<div key={post.id} className="p-3 flex justify-between items-center gap-2 border-2 border-black">
{post.text}
<DeletePost postId={post.id} />
</div>
))}
</div>
<PostsForm />
</main>
);
}
Implementing Update Functionality
First, add an updatePost
function to your posts.ts
file:
export async function updatePost(postId: number, prevState: State, formData: FormData) {
try {
const text = formData.get("text") as string;
await prisma.post.update({
where: { id: postId },
data: { text }
});
revalidatePath("/");
return { message: "Post updated successfully" };
} catch (err) {
console.error(err);
return { message: "Error updating post" };
}
}
Now, create a new page for updating posts. Create a file src/app/post/[id]/update/page.tsx
, as Next.js has file based routing, when we navigate to https://<app-domain>/post/1/update
we will see this page.
import UpdateForm from "@/components/update-form";
import prisma from "@/lib/db";
import { redirect } from "next/navigation";
export default async function UpdatePostPage({
params
}: {
params: { id: string }
}) {
const postId = Number(params.id);
if (!postId) redirect("/");
const post = await prisma.post.findUnique({
where: { id: postId }
});
if (!post) redirect("/");
return <UpdateForm post={post} />;
}
On this page we simply fetch the post to be updated and passes it to an UpdateForm
component.
Create the UpdateForm
component in src/components/update-form.tsx
:
"use client";
import { State, updatePost } from "@/actions/posts";
import { useFormState } from "react-dom";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Post } from "@prisma/client";
import { useState } from "react";
interface UpdateFormProps {
post: Post
}
export default function UpdateForm({ post }: UpdateFormProps) {
const initialState: State = { message: null };
const updatePostWithId = updatePost.bind(null, post.id);
const [state, dispatch] = useFormState(updatePostWithId, initialState);
const [text, setText] = useState(post.text);
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<form action={dispatch} className="flex flex-col gap-4">
{state.message && <div>{state.message}</div>}
<div className="flex gap-2">
<Input
name="text"
type="string"
placeholder="Add some text here."
value={text}
onChange={(e) => setText(e.target.value)}
/>
<Button>Submit</Button>
</div>
</form>
</main>
);
}
Here, we use bind
again to create an updatePostWithId
function, similar to what we did in the delete functionality.
Finally, update your root page.tsx
(in src/app
) file to include the update button:
import DeletePost from "@/components/delete-post";
import PostsForm from "@/components/posts-form";
import { Button } from "@/components/ui/button";
import prisma from "@/lib/db";
import { Post } from "@prisma/client";
import Link from "next/link";
export default async function Home() {
const posts: Post[] = await prisma.post.findMany({
orderBy: { createdAt: 'desc' }
});
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div className="w-full flex flex-col gap-2">
{posts.map((post) => (
<div key={post.id} className="p-3 flex justify-between items-center gap-2 border-2 border-black">
{post.text}
<div className="flex gap-2">
<DeletePost postId={post.id} />
<Link href={`post/${post.id}/update`}>
<Button>Update</Button>
</Link>
</div>
</div>
))}
</div>
<PostsForm />
</main>
);
}
This completes the CRUD functionality for our posts application. We have now implemented Create, Read, Update, and Delete operations using Next.js server actions and Prisma.
You can play around with it by running it locally using npm run dev
in our terminal. In the next section we will look at how we can deploy this application.
Deploying to Vercel
To deploy our application to Vercel, we first need to push our code to GitHub. If you haven't set up GitHub yet, you can follow this tutorial: GitHub Setup Guide.
Once you've set up GitHub and pushed your code, follow these steps:
Go to https://vercel.com/
Create an account if you haven't already
Connect your Vercel account to your GitHub account
After connecting, you'll see this screen with different options to deploy your project.
- Click the "Import" button next to "Import Project", and you will be redirected to the import git repository page, with all your GitHub repositories listed.
- Select the repository you want to deploy and you will be redirected to configure project page, leave everything as it is for now and click "Deploy"
- After clicking deploy you will be redirected to the project dashboard page.
Note: The initial build for our application will fail, but don't worry β we'll fix it in the next sections.
Database for Production
On the project dashboard page, look for the "Storage" option in the top navbar.
Follow these steps to set up a PostgreSQL database:
Click on "Storage", then you will see different options to create a database.
Choose Postgres as your database type, there will be a pop asking you to accept tnc,
Once you click accept, you will get option to select the region to create database.
Select the region closest to you for your database and click "Create".
Once the database is created, you'll see a "Connect" button. Click it to automatically set the relevant environment variables for your database in your project's environment.
Fixing the Build Failure
If you check the deployment logs, you will likely see an error related to Prisma.
To resolve this issue and initialize our production database, we need to modify our build process. Read more about it here: Update your package.json
file as follows:
{
"name": "nextjs-full-stack-guide",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "npx prisma generate && npx prisma migrate deploy && next build",
"start": "next start",
"lint": "next lint"
},
// ... rest of the package.json content
}
The key change is in the "build" script, which now includes the necessary Prisma commands:
"build": "npx prisma generate && npx prisma migrate deploy && next build",
This change ensures that:
Prisma generates the client for our defined database schema
The production database is initialized using our migration files
The Next.js build process runs
After making these changes:
Commit and push the changes to GitHub
A new build on Vercel will automatically trigger and should pass successfully
Workflow for Future Development
Here's the workflow for continuing development on your application:
Start Docker Desktop
Spin up the database container:
docker compose up
Run
npx prisma migrate dev
to update the local database according to your schemaStart the development server:
npm run dev
Make changes to your application
If you change the database schema, run
npx prisma migrate dev
Push your code to GitHub
Vercel will automatically trigger a new deployment
With this setup, you now have a foundational structure for building and deploying full-stack Next.js applications with a PostgreSQL database.
Thankyou for reading till the end. I hope you learned something new. Have a great day!