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

Β·

17 min read

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:

  1. 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.

  2. 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.

  3. 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:

  1. 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.

  2. Prisma ORM: An Object-Relational Mapping (ORM) tool that provides a simple syntax to interact with databases.

  3. PostgreSQL: We will use it as our relational database system.

  4. 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:

  1. Open your terminal and navigate to the directory where you want to create your project.

  2. Create a new directory for your project: mkdir <your-app-name>

  3. Open the new directory in VS Code: code <your-app-name>

  4. 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:

Nextjs Interactive Section

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:

  1. Run npx shadcn-ui@latest init

  2. 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 components

  • lib: 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.

Prisma Studio

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:

  1. Every time you make a change to your code, Next.js reloads the affected modules.

  2. This reload can trigger the creation of a new Prisma Client instance.

  3. Each Prisma Client instance opens its own connection pool to the database.

  4. 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:

  1. We declare a prisma variable to hold our Prisma Client instance.

  2. We use declare global to add the prisma property to the global namespace. This allows us to store the Prisma Client instance in a way that persists across hot reloads.

  3. In production, we simply create a new Prisma Client instance as normal.

  4. 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.

  5. 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:

  1. Extracts the "text" field from the form data.

  2. If text is provided, it creates a new post in the database.

  3. Calls revalidatePath("/") to clear the cache for the home page, ensuring we see the new post immediately.

  4. 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:

  1. useFormState is a React hook provided by the react-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)

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

  3. 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.

  4. 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.

  5. 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:

  1. Go to https://vercel.com/

  2. Create an account if you haven't already

  3. Connect your Vercel account to your GitHub account

  4. After connecting, you'll see this screen with different options to deploy your project.

Vercel Dashboard

  1. 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.

Vercel Imports

  1. 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"

Vercel project configuration

  1. After clicking deploy you will be redirected to the project dashboard page.

Vercel Project Dashboard

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.

Vercel Project dashboard with storage icon marked

Follow these steps to set up a PostgreSQL database:

  1. Click on "Storage", then you will see different options to create a database.

  2. Choose Postgres as your database type, there will be a pop asking you to accept tnc,

  3. Once you click accept, you will get option to select the region to create database.

  4. 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.

Vercel Deployment Logs

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:

  1. Prisma generates the client for our defined database schema

  2. The production database is initialized using our migration files

  3. The Next.js build process runs

After making these changes:

  1. Commit and push the changes to GitHub

  2. 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:

  1. Start Docker Desktop

  2. Spin up the database container: docker compose up

  3. Run npx prisma migrate dev to update the local database according to your schema

  4. Start the development server: npm run dev

  5. Make changes to your application

  6. If you change the database schema, run npx prisma migrate dev

  7. Push your code to GitHub

  8. 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!

Β