Contents

Modern Full Stack Application Architecture Using Next.js 15+

Modern Full Stack Application Architecture Using Next.js 15+ webp image

The React library has been popular in the frontend world for many years. It allows developers to build interactive user interfaces with reusable components that can be composed together. This modular approach makes the application scalable and easy to maintain.

As a library, React allows developers to select additional tools, such as routing, state management, data fetching, caching, or page rendering strategies. This flexible approach gives a lot of freedom. Yet, in practice, it boils down to making architectural decisions, such as how to structure folders and files, what naming conventions to adopt, how to secure the application at the SSR level, what approach to take for SEO, or, finally, how to take care of the developer experience. Developers must, therefore, spend time configuring tools and reinventing solutions for typical application requirements.

Introduction

Among frameworks that offer such configuration and tools “out of the box”, Next.js is dominant. Currently, it is the most mature framework and, therefore, a fairly obvious choice, especially for React developers or ones who want to build their applications in the React ecosystem.

Next.js prioritizes server-side rendering (SSR) as its primary rendering technique. It also enables the use of static-site generation (SSG), incremental static regeneration (ISR), and client-side rendering (CSR). In addition, it provides React Server Components (RSC) and React Server Functions (RSF) as basic architectural elements. Moreover, Next.js allows to mix and match these rendering techniques; for example, a landing page can use SSG, while an application with authentication (dashboard) can use SSR.

React itself allows the creation of applications that run only on the client side. So, if the backend is needed for it (for example, for handling database queries, or authentication), it needs to be built separately and integrated with the React application. Next.js, on the other hand, enables the development of full-stack applications. It allows the developer to focus on both the frontend and the backend in one framework.

Key features of Next.js

  1. SSR - built-in support for rendering pages on the server, improving performance and SEO
  2. SSG - support for static site generation, where pages are generated at compile time and served as static HTML files for faster loading and reduced server load
  3. Automatic code splitting - Next.js splits code into smaller and more manageable chunks, optimizing performance and loading time
  4. CSS support- support for various CSS solutions including CSS modules, CSS-in-JS libraries, global CSS styles, providing flexibility in application styling
  5. Data fetching - support for various methods of fetching data at compile time or at request time
  6. Routing - is greatly simplified due to automatic route generation based on file structure and page directories
  7. Image optimization - automatic optimization to ensure optimal loading performance by resizing and compressing images
  8. Built-in CSS and JavaScript bundling - automatically optimizes and bundles CSS and JS for more efficient loading
  9. API proxying - allows to create API routes that can serve as proxies for third-party APIs, supporting fetching data and security
  10. Internationalization support - Next.js provides tools and libraries for multi-language support

Considering the indicated features and advantages of Next.js, it is worth considering in which cases it is justified to use this framework instead of pure React. Next.js will, therefore, be useful for content-rich websites, i.e., blogs, or e-commerce, where at the same time SEO plays an important role. Next.js can significantly improve the performance of applications, resulting in the user not needing to fetch and run a lot of JS on the device, which is important, especially on slower devices or slower networks.

Thus, staying with pure React will be more suitable in situations where we are dealing with:

  • Microservices architecture, or there is logic heavily based on the backend
  • An application that relies heavily on real-time updates
  • Highly customized user interface such as animations, interactions, and complex layouts

We are past the introduction, so it is time to get to specifics. I will present the remaining concepts a developer should consider when building an application in Next.js by scaffolding and configuring the application to make CRUD operations on articles, including authentication.

Starting the project

In the terminal:

npx create-next-app@latest

After running this command, we answer several prompts, and soon after all requirements will be automatically configured.

starting%20the%20project%20prompts%20to%20answer

Project’s structure

One of the questions concerns the choice of router. Next.js has two types of them:

  1. App Router - a newer router that supports new React features like Server Components and Server Actions,
  2. Pages Router - uses a file-system router to map each file to a route. Before version 13, it was the main way to create routes. It is still supported in newer versions.

Before Next.js introduced App Router, building in-app routing was based on Pages Router. In essence, it used React Router built into Next.js to handle navigation between pages automatically.

The Pages Router technique boils down to creating .js or .ts files in the pages directory, and Next.js will automatically create sub-pages of the application from them. For example, creating such a structure: pages/users.ts will generate a page at /users.

When a request is needed to be sent to the server in Pages Router, there are getServerSideProps or getStaticProps methods which can be used in the pages files.

The newer (from version 13) and recommended approach, App Router, gives developers more control and flexibility. App Router uses directories to define routes, allowing more advanced features like nested layouts.

Having an app/users/page.ts structure, this page is by default a server component (executed on the server), and the /users path becomes the route. In addition, it is possible to create layouts that will be shared on multiple routes, making it easier to maintain a consistent design and structure throughout the application.

In Next.js, reserved folders and file names have special meanings for the application. For example, for folders those will be: src, public, app, [folder], […folder]. For files, those will be for example, (with extensions .ts, .js, .tsx, .jsx, respectively): middleware, layout, page, loading, error. Their location in the project structure determines how pages and components are rendered and how the entire application behaves.

For more detailed information, see: project structure and organization

Server Actions

With the newer approach, server-side operations such as data fetching, form submission, and database interactions are done via Server Actions. In the App Router, all requests are server-side by default, which simplifies the process of communicating with the server before the page is rendered. Server Actions can be called in both Server and Client Components.

Server Actions are defined using the 'use server' directive. Next.js needs information that a function or all the exports in the file are to be treated as a Server Action. Without this directive, Next.js will not recognize whether a function is local (used only by server components) or whether it should be exported as Server Actions and called by the client. This is expected because Next.js allows hybrid mixing of Client and Server Components. And only functions that are marked with the 'use server' directive can be called by the client.

For example:

'use server';

import { eq } from 'drizzle-orm';
import db from '@/db';
import { articles } from '@/db/schema';

export async function getArticleBySlug(slug: string) {
  return await db.query.articles.findFirst({
    where: eq(articles.slug, slug),
  });
}

With the 'use server' directive, the getArticleBySlug function can be called in both:

Server Component:

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const data = await getArticleBySlug(slug);
  // ...
}

and Client Component (like passing this function as a callback to an event handler or in useEffect):

or:

'use client';

import { createArticle } from '@/lib/actions/article.actions';
// ...

export const ArticleForm = () => {
  return (
    <form action={createArticle}>
    {/* ... */}
    <button type='submit'>Create</button>
    </form>
  );
};

or:

'use client';

import { createArticle } from '@/lib/actions/article.actions';
// ...

export const ArticleForm = () => {
  return (
    <>
    {/* ... */}
    <button
        onClick={async () => {
        const formData = new FormData();
        formData.append('title', 'Article title');
        await createArticle(formData);
        }}
    >
        Create
    </button>
    </>
  );
};

If the 'use server' directive were omitted, Next.js could treat such a function as an ordinary helper function. Consequently the function would not be registered as a Server Action and could not be dynamically called from the Client Component.

The 'use server' directive could be placed at the top of an asynchronous function to mark that function as Server Action or at the top of a separate file to mark all exports of that file as Server Actions:

// Directive at the top of a separate file
'use server';

export async function createArticle(article: Article): Promise<ReturnType> {
  const parsedArticle = articleFormSchema.parse(article);

  if (!parsedArticle.success) return { message: 'Something went wrong' };

  return { message: 'Article created' };
}

// Directive at the top of an async function
export function Page() {
  const createArticle = async () => {
    'use server';
    // ...
  };

  return <form action={createArticle}>...</form>;
}

Server and Client Components

Now let’s answer the question of when to use Client and Server Components.

By default, layouts and pages are Server Components. It allows sending server requests and rendering parts of the user interface on the server, optionally caching and passing the results to the client. In contrast, you can use Client Components when interaction or access to the browser API is needed. Creating a Client Component involves adding a 'use client' directive at the top of the file, above the imports. Once a file is marked with the 'use client' directive, all its imports and subcomponents are considered part of the client bundle. It also means there is no need to add a 'use client' directive to every component intended for the client.

When to use Client Components:

  • Component state management and event handling (e.g. onClick, onChange)
  • Component lifecycle usage (e.g. useEffect)
  • Browser API access (localStorage, window, geolocation)
  • Custom hooks

When to use Server Components:

  • Communicating with the database
  • Using API keys, tokens, other sensitive data that should not be accessible to the client
  • Limiting the amount of JavaScript sent to the browser
  • Improving First Contentful Paint and sending content progressively to the client

Package manager

When initializing the project, we used npx command, which enables running Node.js packages from the npm registry on the fly, without installing them globally. As for the package manager, npm is the most commonly used one, mainly because it comes with every Node.js installation. However yarn, pnpm and bun are good alternatives, especially pnpm because of its better performance.

Should a need arise to create several applications that depend on each other or share a common set of components, the concept of monorepo is worth considering. For that, tools such as Nx or Turborepo might work well.

Linter and code formatter

Once the project has been initialized, it is worth taking care of a unified code style in the next step. The ESLint linter will help with this, as it enforces a certain code style and will point out an error in our IDE if specified rules are not followed.

A code formatter such as Prettier should be an addition to this. It can be configured so that the code will be formatted - according to specific rules - every time the file is saved. Prettier is not a replacement for ESLint, but it integrates well with it.

Install the package with:

npm install --save-dev eslint-config-prettier

Additionally, add information about the package to the ESLint configuration file (eslint.config.mjs).

By the way, remember to install a package for sorting import declarations according to a certain order. For example, third-party libraries will be at the top, followed by alias imports and finally relative imports, plus there are spaces between these groups, making this part of the file clean and readable:

npm install --save-dev @trivago/prettier-plugin-sort-imports

Then, add this package to the list of plugins in the .prettierrc.json file and specify the sorting rules:

{
  "importOrder": [
    "^(react|next?/?([a-zA-Z/]*))$",
    "<THIRD_PARTY_MODULES>",
    "^@/(.*)$",
    "^[./]"
  ],
  "importOrderSeparation": true,
  "importOrderSortSpecifiers": true,
  "plugins": ["@trivago/prettier-plugin-sort-imports"]
}

Styling

The application is ready to use Tailwind CSS. Obviously, it is a matter of need and taste how the application will be styled. However, it is worth giving this framework a chance because of its approach to utility classes, which are easily composable to build any project. What sets Tailwind CSS apart is that it does not give us ready-made components, but tools to build our own components.

The traditional approach used classes like: container, section-wrapper, accordion, heading-1, notification and so on. In Tailwind CSS, there are no such classes, but instead a set of simple tools, such as flex (which gives display: flex), w-screen (width: 100vw), text-lg (gives both font-size of a certain size and line-height of a certain size), and so on. I encourage you to read the documentation.

In various projects, one might come across various user interface styling requirements. These range from a defined system design, where custom components are required, to a more flexible approach, where one can rely on off-the-shelf solutions. One of the popular component libraries these days is shadcn/ui, which uses Tailwind primitive classes and creates ready-made components from them. Moreover, there is no need to install this library as a dependency in the project, but the code is dropped into the components directory, so we have access to the source code. The shadcn/ui components come with basic styling, making them easily configurable for application design.

Other noteworthy libraries include:

Working with environment variables

In the next step, we’ll take a look at configuring the work with environment variables. Next.js will detect the .env file in the project by default and load the environment variables set there, which are then available through process.env. However, it may happen that some variable is not set, or is not of a particular type, such as for the port number we would like it to be numbers and not strings. So, to work with environment variables in a type-safe way, along with automatic validation, it’s worth reaching for the Zod library, which allows us to define a schema for the required variables and validate them at application startup.

However, we can go a step further and use the T3 Env library, which is built on top of Zod and is dedicated to managing environment variables in TypeScript/Next.js projects. In addition, it allows for the separation of client-side and server-side variables.

Installation:

npm install @t3-oss/env-nextjs zod

In the src/env/server.ts file, we create a scheme for server-specific environment variables:

import { createEnv } from "@t3-oss/env-nextjs";
import "dotenv/config";
import { z } from "zod";

export const env = createEnv({
  server: {
    NODE_ENV: z.enum(["development", "production"]),
    AUTH_GOOGLE_ID: z.string(),
    AUTH_GOOGLE_SECRET: z.string(),
    SESSION_SECRET: z.string(),
    DATABASE_URL: z.string().url(),
    CLOUDINARY_NAME: z.string(),
    CLOUDINARY_KEY: z.string(),
    CLOUDINARY_SECRET: z.string(),
  },
  onValidationError: (issues) => {
    console.error("❌ Invalid environment variables:", issues);
    process.exit(1);
  },
  emptyStringAsUndefined: true,
  experimental__runtimeEnv: process.env,
});

When needed, for the client I will want to have a separate src/env/client.ts file because to make the environment variable available in the browser, we need to add the NEXTPUBLIC prefix to it.

It is recommended to import this file into next.config.ts file. This will ensure that the environment variables are checked at compile time. If any variable is missing, the application will not start at all.

To protect our application from using process.env directly in any place and to force the use of the created schema, we can install the eslint-plugin-n plugin:

npm install –save-dev eslint-plugin-n

and use it in the eslint.config.mjs file:

const eslintConfig = [
  ...compat.config({
    plugins: ['n'],
    rules: {
    "n/no-process-env": ["error"]
    }
  })
];

With this, to refer to an environment variable, we must import the env constant defined before:

import { env } from “@/env/server”;

and use it:

env.DATABASE_URL

Handling forms and validations

Forms are an indispensable part of the application. Working with them presents several challenges. We must consider client-side validation to provide users with immediate feedback, and server-side validation to ensure data integrity and form state management. It can quickly become complicated, particularly for multi-step forms or those with dynamic fields. Both the developer and user experience can be significantly improved when using specialized libraries that provide a structured approach to validation, reduce boilerplate, and help maintain a consistent user interface for error states and feedback.

React Hook Form stands out as a performance-oriented form library. It is designed to minimize re-rendering and ensure flexibility. React Hook Form uses a hooks-first approach that reduces the complexity of the component tree and prevents unnecessary re-rendering.

Installation:

npm install react-hook-form

And the example:

import { zodResolver } from "@hookform/resolvers/zod";
import { SubmitHandler, useForm } from "react-hook-form";
import { z } from "zod";

// ...

const {
  register,
  handleSubmit,
  formState: { errors },
} = useForm({
  resolver: zodResolver(articleFormSchema),
  defaultValues: { ... },
});

  const onSubmit: SubmitHandler<z.infer<typeof articleFormSchema>> = async (
    values
  ) => { ... };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
        <div>
        <Input {...register("title", { required: true })} />
        <p>{errors.title?.message}</p>
        </div>
        {/* ... */}
    </form>
  );
};

React Hook Form works great with libraries for schema validation (in the above example, we used zodResolver). While discussing the topic of environment variables, I mentioned the Zod library. It will work perfectly in this case as well. It is a TypeScript-first library. It allows us to define validation schemes via a chain API interface that uses the TypeScript type inference system. One of the key advantages of Zod is its ability to be the single source of truth for both client-side and server-side validation logic. It means we can define a validation schema once and use it throughout the application.

Example:

import { z } from "zod";

export const signUpFormSchema = z
  .object({
    name: z.string().min(3, "Name must be at least 3 characters"),
    email: z.string().email("Invalid email address"),
    password: z.string().min(6, "Password must be at least 6 characters"),
    confirmPassword: z
    .string()
    .min(6, "Confirm password must be at least 6 characters"),
  })
  .refine((data) => data.password === data.confirmPassword, {
    message: "Passwords don't match",
    path: ["confirmPassword"],
  });

On the other hand, for server-side validation, Zod provides a clean and simple way to validate request’s payload:

import { signUpFormSchema } from "@/validations/sign-up.validator";

export async function signup(_state: FormState, formData: FormData) {
  try {
    const user = signUpFormSchema.parse({
    name: formData.get("name"),
    email: formData.get("email"),
    password: formData.get("password"),
    confirmPassword: formData.get("confirmPassword"),
    });
    // ...
  } catch (error) {
    // ...
  }
}

Testing

The foundation of testing is a framework such as Vitest or Jest. It provides tools for running tests, an assertion library, spying, and mocking functions. A complementary tool will also be the React Testing Library. It allows us to render React components and simulate events on HTML elements.

When it comes to choosing a test tool for E2E testing, Playwright is recommended, while Cypress is an alternative. These tools enable automating and simulating user interactions in the browser, ensuring that the application behaves as expected.

Authentication

In the next step, let’s discuss authentication configuration. In general terms, authentication consists of the following concepts:

  • Authentication: is the process of verifying a user’s identity, thus verifying that the person trying to access an application is who they claim to be
  • Session management: tracking the authentication status of a user when requesting resources
  • Authorization: determining what resources and operations a user has access to once they have authenticated with an application

There are many ways to set up authentication in a project. There is an entire article on the Next.js documentation page about how to do this from scratch, along with session management. However, it will not always be necessary to do it from scratch. So, ready-made solutions are recommended, if only for greater security and simplicity. Such libraries offer built-in authentication, session management, and authorization solutions, as well as additional features such as social login, multi-factor authentication, or role-based access control (RBAC). Here Next.js lists compatible authentication and session management libraries.

For our application example, I will focus on Auth.js. It is a complete authentication solution built not only for Next.js but also compatible with other backend frameworks (before version 5, it was NextAuth, and it was only compatible with Next.js). It significantly reduces the time needed to implement secure authentication in a project.

Key features of Auth.js include:

  • Built-in support for popular authentication providers (e.g. Google, GitHub, FaceBook)
  • Email/password authentication option
  • Comprehensive session management
  • Role-based access control
  • Security features like CSRF protection and http-only cookies

I will use two ways to authenticate:

  1. Credentials provider - support login using any credentials, such as email and password
  2. Google provider - login using Google account

For session management, there are two types of sessions:

  1. Stateless - session data is stored in browser cookies. A cookie is sent with each request allowing verification on the server. This is a simpler method, but may be less secure (I will use this method - JWT token
  2. Database - session data is stored in the database, and the user’s browser receives only encrypted session ID. This method is more secure, but can be more complicated and use more server resources

Installation:

npm install next-auth@beta

Configuration in @/auth.ts file:

import NextAuth, { type User } from "next-auth";
import Credentials from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";

export const { handlers, signIn, signOut, auth } = NextAuth({
  session: {
    strategy: "jwt",
  },
  providers: [
    GoogleProvider,
    Credentials({
    credentials: {
        email: {},
        password: {},
    },
    authorize: async (credentials) => {
        // ...
    },
    }),
  ],
});

Then, to secure the route we can use:

import { auth } from '@/auth';

// ...

const session = await auth();

if (!session?.user) {
  return redirect('/sign-in');
}

Here you can find more details about installing Auth.js.

Database integration

When developing any Next.js application, we often have to deal with a database ORM. Among several, there are two very popular ones: Prisma and Drizzle ORM.

I will opt for Drizzle ORM. Drizzle is distinguished by the fact that there is no need for code generation, which is characteristic of Prisma. In Prisma, a schema needs to be defined first and then run the npx prisma generate command (after any change in the schema file). This step generates the Prisma client (@prisma/client) and the model's type definition.

In Drizzle, the definition of a table in TypeScript is at the same time the definition of types ready for use, without the need to run separate commands to generate code. Thus, any schema change in Drizzle does not require an additional step to update the types and ORM client.

In addition, Drizzle has built-in helper functions that take the same table definitions and turn them into Zod validators (again, no additional code generation).

Moreover, Drizzle has different adapters depending on what type of database is used in the project. PostgreSQL, MySQL, SQLite, Neon, Supabase, among others, are supported (you can read more here). For each of these types, there are many different ways to connect databases.

I want to connect to PostgreSQL, which runs inside a Docker container.

Installation:

npm i drizzle-orm postgres
npm i -D drizzle-kit

Driver initialization:

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "@/env/server";
import * as schema from "./schema/index";

const queryClient = postgres(env.DATABASE_URL);
const db = drizzle({ client: queryClient, schema });

export default db;

Drizzle configuration (drizzle.config.ts):

import { defineConfig } from "drizzle-kit";
import { env } from "@/env/server";

export default defineConfig({
  out: "./src/db/migrations",
  schema: "./src/db/schema/index.ts",
  dialect: "postgresql",
  dbCredentials: {
    url: env.DATABASE_URL!,
  },
});

Sample schema (src/db/articles.ts):

import { relations } from "drizzle-orm";
import {
  boolean,
  index,
  pgTable,
  text,
  timestamp,
  uniqueIndex,
  uuid,
  varchar,
} from "drizzle-orm/pg-core";
import users from "./users";

const articles = pgTable(
  "article",
  {
    id: uuid().primaryKey().defaultRandom(),
    title: varchar("title", { length: 255 }),
    slug: varchar("slug", { length: 255 }).notNull(),
    content: text("content").notNull(),
    image: varchar("image", { length: 2048 }),
    isPublic: boolean("is_public").default(false),
    authorId: uuid("author_id")
    .notNull()
    .references(() => users.id, { onDelete: "cascade" }),
    createdAt: timestamp("created_at", { mode: "string" })
    .notNull()
    .defaultNow(),
    updatedAt: timestamp("updated_at", { mode: "string" })
    .notNull()
    .defaultNow(),
  },
  (table) => [
    uniqueIndex("slug_idx").on(table.slug),
    index("title_idx").on(table.title),
  ]
);

export const articlesRelations = relations(articles, ({ one }) => ({
  author: one(users, {
    fields: [articles.authorId],
    references: [users.id],
  }),
}));

This schema is imported in src/db/schema/index.ts file and plugged in drizzle.config.ts configuration. To query the database, let’s create a helper function in the src/lib/actions/article.actions.ts file:

"use server";

import db from "@/db";

// ...

export async function getArticleBySlug(slug: string) {
  const article = await db.query.articles.findFirst({
    where: eq(articles.slug, slug),
  });

  return article;
}

Now, this method can be called in the page file:

export default async function Page({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const article = await getArticleBySlug(slug);
  // ...
}

Docker containerization

To run a database in a Docker container, make sure to have Docker Desktop installed, enabling easy container management. There are many ways to run a Docker container - for example, directly from the command line or pre-made images - but let’s follow a more structured way. Create a docker-compose.yml file and put all the necessary information (like PostgreSQL image, ports, and environment variables) in it:

services:
  database:
    image: postgres
    container_name: readium_postgres
    environment:
    POSTGRES_USER: postgres
    POSTGRES_PASSWORD: postgres
    POSTGRES_DB: readium
    ports:
    - 5432:5432
    volumes:
    - dockerreadiumdata:/var/lib/postgresql/data
    healthcheck:
    test: ["CMD-SHELL", "pg_isready -U postgres -d readium"]
    interval: 10s
    timeout: 5s
    retries: 5
    restart: unless-stopped

volumes:
  dockerreadiumdata:

Thus, starting the database will come down to calling a command in the terminal:

docker compose up

Deployment

Deploying and hosting a Next.js application is similar to deploying any other application, and much of it depends on the needs and decisions of developers and business decisions.

Next.js can, therefore, be deployed:

  • As a Node.js server with any vendor supporting this environment
  • With any vendor supporting Docker containers, including container orchestration such as Kubernetes or a cloud provider
  • As a static export (for static pages or SPAs)
  • Or, last but not least, customized to run on different platforms supporting infrastructure

Here you will find more information.

Summary

In this article, I took a step-by-step look at modern full stack application architecture using Next.js 15 and such technologies as TypeScript, Server Actions, Server and Client Components, Auth.js, Tailwind CSS, Drizzle ORM, PostgreSQL, and Docker. I have shown not only how to connect these tools but also how to build a clear project structure, manage state and types, and ensure a clear division of logic between the frontend and backend.

I hope this knowledge will help you create robust and scalable applications. If you want to see the complete code of the application discussed in the article, you can find it in the public repository on GitHub.

Blog Comments powered by Disqus.