Full Stack Web App Built with Next.js App Router and PostgreSQL

Jul 3, 2025

software-development

Since my experience in front-end development is mostly with React, I recently built a full-stack movie app using the Next.js App Router and PostgreSQL to get more comfortable with these technologies. The app supports searching, adding, editing, and deleting movies, as well as authentication. It’s open source.

This post is a companion to the source code and gives an overview of the project structure along with explanations of files and directories using a tree UI component. I also share some issues I faced and how I overcame them.

It’s assumed that you have a basic familiarity with the Next.js App Router.

Project Structure

+
app
This is the Next.js app directory. The Next.js app directory is essentially a routing system that uses folders and special files (e.g. page.tsx) to define routes. It also supports layouts which is basically a UI that is shared between multiple pages.
+
components
Inside this folder, components using client-specific features (e.g. state, effects) go in the client folder. Those using server-specific features (e.g. async components) go in the server folder. Components that use neither can technically work in both contexts (for example if they're imported by a client component, they'll automatically become part of the client bundle) - so they are placed outside of these folders.
+
hooks
Custom React hooks.
+
lib
Folder for some server-side logic.
+
server-functions
Server functions allow client components to call asynchronous functions that are executed on the server. When that function is called on the client, React will send a request to the server to execute the function, and return the result. They are used for updating data.
+
types
TypeScript type definitions.
middleware.ts
Middleware for protecting routes based on authentication.
utils.ts
Shared utilities for server and client.

Hydration Mismatch Errors, Fixing Initial Render Flicker

Sometimes in Next.js you may encounter hydration mismatch errors. Before we understand what these errors are, let's first briefly review what hydration means.

In a Next.js app, when you visit a page which uses server side rendering (SSR), that page is pre-rendered on the server and the resulting HTML is sent to the client. This HTML contains the content and structure of the page but without the JavaScript logic that makes it interactive. The reason this is done is to give the user something meaningful to look at as quickly as possible. Once this initial HTML arrives in the browser, Next.js runs JavaScript on the client to make the page fully interactive — a process called hydration. Hydration mainly happens in two steps:

  • React uses the same component tree that was used during SSR and renders it again on the client in memory (no DOM nodes are generated) and then checks that this output matches the HTML sent from the server. The reason why such check is performed is explained in the docs:

    This is important for the user experience. The user will spend some time looking at the server-generated HTML before your JavaScript code loads. Server rendering creates an illusion that the app loads faster by showing the HTML snapshot of its output. Suddenly showing different content breaks that illusion. This is why the server render output must match the initial render output on the client.

  • React will make the HTML sent by the server interactive (e.g. attach event listeners).

A hydration mismatch error happens when the first step above is not satisfied: the HTML produced on the server doesn’t match what React renders in memory on the client during hydration.

One of the reasons why such error may happen is if your rendering process is based on information that is only available on the client side, such as local storage. But there can also be other reasons. During SSR such client information is not available, so the HTML produced on the server may differ from the in-memory render created during hydration on the client.

One solution to fix such error is to skip SSR for the component entirely:

import { useState, useEffect } from 'react'

export default function SampleComponent() {
   const [isClient, setIsClient] = useState(false)

   useEffect(() => {
     setIsClient(true)
   }, [])

   if(!isClient) return null;

   return <h1>Hello World</h1>
}

This works because useEffect only runs on the client. On the server, isClient is false, so the component returns null and effectively no HTML is generated. While this approach often resolves hydration errors, it also disables SSR for the component and all of its children. Such trade-off can be problematic for components which have many children or content important for SEO.

My Case: Sidebar State

In my case I store information whether sidebar is open or not in local storage and need it in some components such as the (main)/layout.tsx. I could read this value from local storage directly in useState initializer function as shown below:

export default function Layout() {
  // I don't recommend this approach. It can cause hydration errors.
  // Also IMHO reading localStorage here makes the initializer function impure.
  // Based on the docs, that function should be pure.
  const [showSidebar, setShowSidebar] = useState(() => {
    if (typeof window === "undefined") return false; // SSR fallback
    const stored = localStorage.getItem("sidebarOpen");
    return stored ? JSON.parse(stored) : false;
  });

  // ...
}

However, this approach has two problems:

  • Hydration mismatch error: during SSR, showSidebar would be false, and on the first render during hydration it could be true, which would result in different render output.
  • Purity: According to the React docs, the useState initializer function should be a pure function and I am not sure if reading local storage inside it is considered pure.

Also I would not be able to fix the hydration error using fix from the previous section which skips SSR for the component, because it would opt out children of layout from SSR too, which is basically my whole app.

So I decided to use another approach, which was to read the value of the sidebar from local storage in useEffect instead of useState initializer function. This does not cause hydration error because useEffect runs after hydration and the initial false value is the same during SSR and the first render during hydration.

export default function Layout() {
  const [showSidebar, setShowSidebar] = useState(false);

  // Read localStorage after the component mounts
  useEffect(() => {
    const stored = localStorage.getItem("sidebarOpen");
    if (stored) {
      setShowSidebar(JSON.parse(stored));
    }
  }, []);

  // ...
}

However, this approach had another problem, it caused a brief "flicker" initially as the sidebar state updated from closed to open after useEffect was run.

Finally, I ended up with a solution, which had neither the flicker problem nor hydration error. I added an inline script that runs before React hydrates, immediately applying the correct sidebar state to the DOM - now the user doesn’t see the flicker. And also there is no hydration error because I made sure there was no difference between the DOM built from the server HTML (after inline script is run) and what was rendered during hydration. The solution is in the (main)/layout.tsx file and is based on this post.

Redirect From Middleware Not Working Properly With Server Functions

Currently even Nextjs docs mention that you can perform auth checks inside middleware (though they mention this should not be your only line of defense). So you often encounter code like this inside middleware:

if (isProtectedRoute && !session) {
  return NextResponse.redirect(new URL('/login', req.nextUrl))
}

This works most of the time but there was one situation where it was causing a problem.

Suppose we are on a protected page and the session expires. If then I invoked a server function from that page:

startTransition(async () => {
  // Call a server function
  const result = await addMovie(data);
  // ...
});

the addMovie call triggered the middleware. Because we are on a protected page and the session had expired, the middleware attempted to redirect using NextResponse.redirect (as shown in the previous if check). However apparently since the middleware was called due to server function, the redirect didn't happen. The addMovie function itself never ran and simply returned undefined.

It seems others have also encountered this issue, so my solution if from here, to configure the middleware such that it ignores server functions. I do auth checks in server functions though.

Source Code

You can find the source code here.