Command Palette

Search for a command to run...

Building a Policy Forum: Next.js, Go, and LLM Summaries

Timeline

Built With

Next.js 16/Tailwind CSS/Go/Shadcn UI/PostgreSQL/pgx

Introduction

This is a custom project using Next.js for the frontend and Go for the backend. I decided to build this without relying on video tutorials or courses: every coding and architectural decision is the result of brainstorming sessions with LLM.

Initially, I just wanted a sandbox to combine my Next.js and Go experience. Gemini suggested building a standard Invoice PDF Generator. I pushed back because I wanted to implement an LLM-driven summary feature. We eventually agreed on a Policy Forum project. Users can register accounts, complete KYC verification, and post their thoughts. It functions as a standard CRUD app at its core, but the twist is using a lightweight LLM to synthesize posts from different users into a single, cohesive summary.

Project Structure

Because I am using Next.js and Go, I organized this as a monorepo—a backend and frontend folder living side-by-side in the same repository. I adopted the recommended Go backend directory structure and the standard Next.js app router structure for the frontend.

On the frontend, I separated pages with distinct purposes like sign-in and sign-up into distinct routes to make metadata and SEO easier to handle. I also stuck strictly to the principle of keeping client components as small as possible and pushing them as far down the component tree as I could.

I won't detail every single architectural decision or CRUD function. I will only focus on the highly relevant technical hurdles.

Taming CORS (Cross-Origin Resource Sharing)

The very first roadblock was handling CORS. Since the Next.js frontend and the Go backend run on different URLs during development, the backend needs a middleware to explicitly trust the frontend. Setting up CORS means telling the backend which frontend URLs, HTTP methods, and headers are safe.

One fascinating detail about allowed methods is the OPTIONS method.

To understand why it matters, it helps to step back and look at the kind of attack CORS exists to prevent in the first place. Imagine a user visits the real website at https://www.policy-forum.com(opens in a new tab), logs in, and receives a session cookie in their browser. Later, that same user stumbles onto a malicious website at https://www.policy-fake.com(opens in a new tab). A hacker could write a JavaScript fetch request aiming at https://api.policy-forum.com/delete-account(opens in a new tab) using the DELETE method to silently wipe the user's account.

Here is how the browser defends against this. When the browser is about to send a cross-origin request that isn't "simple"—and a DELETE qualifies—it pauses the real request and first fires an invisible preflight OPTIONS request to the Go server. The browser forcefully attaches an unfakeable header: Origin: https://www.policy-fake.com.

The Go server processes this preflight and sends back its configured rules: Access-Control-Allow-Origin: https://www.policy-forum.com. The browser receives this, compares the origins, sees a mismatch, and flat-out refuses to send the actual DELETE request.

Because this protection is hardcoded into the browser itself, a hacker could bypass it using tools like Postman or a custom Python script. However, since those tools run outside the user's browser, they won't carry the user's authentic session. The Go server will simply reject the naked request as unauthorized.

That is the textbook story for why CORS and preflights exist. The reason OPTIONS matters in this application is more mundane, and worth being precise about. The browser only sends a preflight when a request falls outside the "simple" category—roughly, anything beyond a plain GET or a form-style POST. Two things I do on nearly every authenticated call push my requests out of that category. My login and register calls send Content-Type: application/json, which is not one of the content types the browser considers simple. And my authenticated requests attach an Authorization: Bearer header, which is not on the safelist either. Either one on its own forces a preflight.

So if my CORS middleware didn't answer that OPTIONS call, the attack scenario wouldn't be my problem—my own frontend would break first. The browser would preflight my legitimate POST /api/auth/login, get no valid response, and never send the real request. The OPTIONS branch in the middleware exists to keep my own app talking to itself.

With CORS configured and a successful connection established between frontend and backend, I moved on to the database layer.

Database Architecture

For local development, I am spinning up a PostgreSQL database using Docker. I plan to migrate to a serverless database platform once the project is ready for deployment.

In the Go ecosystem, you will see the pq driver in a lot of older tutorials. I decided to use the modern alternative: the pgx library. For schema migrations, I chose goose, and for generating type-safe Go code from SQL queries, I went with sqlc. I prefer this stack because it keeps me in control. I am writing pure PostgreSQL queries instead of learning a proprietary ORM syntax.

The queries themselves are written in raw SQL. The special comments are required by sqlc to generate the matching Go interfaces and structs.

The pgx Type Override Problem

One hurdle I hit immediately was an incompatible type error generated by sqlc. Because I am using the pgx driver, sqlc sees TIMESTAMP WITH TIME ZONE and generates a custom pgtype.Timestamptz. It does the same for UUIDs, generating pgtype.UUID.

To keep my Go application code clean and agnostic to the specific database driver, I had to explicitly override these types in the sqlc.yaml configuration file to use standard Go types. The pg_catalog prefix is just the internal namespace PostgreSQL uses for its default types.

While this configuration solves the compatibility issue, the reason pgx introduces these custom types in the first place is actually a brilliant piece of performance optimization.

Shifting ID Generation to the Server

Another architectural decision I made was to keep the database schema as dumb as possible. PostgreSQL is perfectly capable of generating its own UUIDs and timestamps using functions like gen_random_uuid() or NOW(). I chose to leave those out of the SQL layer entirely and handle the generation inside the Go server.

From a distributed systems perspective, relying on the database for generation creates blind spots. Modern applications often rely on serverless deployments. Your Go server might spin up on Vercel in one geographic region, while your database is hosted in an entirely different region. If they aren't strictly synchronized to UTC, you end up with corrupted, mismatched timestamp data.

There is also the problem of idempotency during network retries. If the Go server sends an INSERT query but the network connection drops right before the database sends back the success acknowledgment, the Go server assumes the request failed. It will retry and send the same query again. If PostgreSQL is generating the IDs, it will happily create a second, duplicate user with a brand new ID.

By generating the UUID in the Go server, a network retry simply attempts to insert the exact same UUID. The database will catch the duplicate primary key and safely reject it, preventing duplicate records.

Wiring pgxpool

Under the hood, pgx is a Go library that translates our Go code into the Postgres Wire Protocol—the binary language the database actually understands—sends it over the network, and translates the binary response back into Go structs.

With the logic sorted, the next step was wiring pgx into main.go. A production database requires a connection pool to safely handle concurrent HTTP requests.

I installed pgxpool:

To set up the database pool, we need to pass it a context. I used the root context context.Background() because it has no timeout and never cancels. This is exactly what we want for a connection pool: it needs to stay alive for the entire lifecycle of the application until we explicitly turn the server off.

Instead of taking the lazy route and defining a global var DB *pgxpool.Pool, I created an application struct. By attaching the database pool to this struct and turning all HTTP handlers into methods on this struct, I can safely pass the database connection to any route that needs it. Global variables make unit testing a nightmare and invite race conditions, so the struct approach is much safer.

Pay close attention to the line app := &application{ db: store.New(pool) }. When we run sqlc generate, it creates a internal/store/db.go file containing a Queries struct and a New() function. Calling store.New(pool) requests that Queries object, which is packed with all our database functions. By attaching it to our app struct, we can simply call app.db.CreateUser() from anywhere in our handlers later on.

Register User

Next, I built out the register handler to parse incoming requests, create the user, and send a clean JSON response back to the Next.js frontend.

First, I defined the exact structure of the incoming JSON payload using standard struct tags.

The logic here follows standard Go best practices. I set up the proper struct, handled errors immediately after every function call, and generated critical values like the UUID and the UTC timestamp right in the handler rather than leaning on the database.

Sign-Up Form

Over on the frontend, I used react-hook-form based on the official Shadcn UI documentation(opens in a new tab). The only major departure from their guide is that I am using a custom fetch function because I need to communicate directly with my Go backend instead of a standard Next.js API route.

To keep things clean, I created a small helper function for all my Go server requests. This saves me from manually updating URLs across the entire codebase when I eventually deploy the application to a production domain. I also rely on t3-env(opens in a new tab) to ensure my environment variables are type-safe.

Password Hashing and JWTs

Before writing the login function, I needed to tackle password hashing and JSON Web Tokens. I won't dump the entire contents of my password.go file here because it relies heavily on standard cryptography libraries.

The key takeaway is my choice of algorithm: argon2id. You will see bcrypt used in a lot of older tutorials, but modern security practices recommend argon2id because its memory-hard properties make it significantly more resistant to modern GPU brute-force attacks.

Before generating the JWT, we need a secret key. I installed godotenv to load my .env file securely instead of relying purely on system-level environment variables.

I created the .env file at the root of the backend folder. For local development, it looks like this:

You can generate a genuinely secure key by running openssl rand -base64 32 in your terminal.

Inside main.go, I load these variables right as the server boots up and attach the JWT secret to the application struct so our handlers can access it safely.

Next, I set up the functions to generate and verify tokens inside internal/auth/jwt.go.

You can customize the JWT struct depending on your application's specific needs. I chose to embed the user_id and kyc_status directly in the claims. This allows my backend middleware to perform quick authorization checks without needing to query the database on every single request.

Signing In The User

With our crypto tools ready, we can build the login handler. First, I wrote the SQL query to retrieve a user by their email.

Running sqlc generate turns this into a type-safe Go method.

The login flow is straightforward: retrieve the user by email, compare the hashed password, and if everything matches, generate the JWT and send it back to the client.

Managing the Session in Next.js

Once the Go server verifies the credentials and returns the JWT, the frontend needs to store it safely. Storing tokens directly in an HTTP-only browser cookie is significantly more secure against cross-site scripting attacks compared to leaving them exposed in local storage.

Because we are using Next.js, we can utilize Server Actions to securely interact with the browser's cookies from the server side. I also set up a delete function to handle user logouts.

The sign-in form is practically identical to the sign-up form. The only real difference is the submission logic.

I included revalidatePath in the session logic because my frontend header component dynamically renders a user profile widget when someone is logged in. Whenever a session is created or destroyed, this command forces Next.js to re-evaluate the layout and update the UI instantly.

Once a user successfully logs in, they are redirected to the dashboard. This is a private route. Any unauthenticated user attempting to access /dashboard should be bounced back to /sign-in.

To achieve this, I implemented two layers of security. The first layer lives in the Next.js middleware. Because Next.js is a full-stack framework, we can intercept requests at the edge before a page even renders. This check is incredibly fast and efficient since we do not even need to make a network trip to the Go backend.

This proxy only checks for the sheer existence of a session cookie, making it blazing fast. It does not cryptographically verify the JWT inside the cookie.

When a page actually needs to display sensitive user information, we must verify the token's authenticity. For this, we need to talk to the Go backend.

I created a session.ts file with a getSession function. I wrapped this function in the React cache utility. This prevents duplicate network requests if multiple components on the same page ask for the user session simultaneously.

This function sends a request to the Go server at /api/users/me and attaches the Bearer token extracted from the cookie. The Go handler verifies the token and returns the user data if valid.

Go Authorization Middleware

Over on the Go side, we need a corresponding middleware to intercept incoming requests, extract the Bearer token, and verify it.

I wrote this functionality as a middleware rather than stuffing it into a standard handler. I want the handler responsible for fetching user profiles to be completely dumb and focused on a single task. By extracting the verification logic into a middleware located in cmd/api/middleware.go, I can easily wrap any future API endpoints that require authentication.

This middleware does more than just block bad requests. It extracts the UUID from the verified JWT and passes it down to the next handler.

Because Go HTTP handlers have a strict signature requiring only a response writer and a request pointer, we cannot simply add a third parameter for the user ID. Instead, Go provides a Context object attached to the request. Think of it like a secure backpack where we can store key-value pairs for the lifespan of that specific HTTP request.

The intuitive approach would be to store it using a simple string:

However, doing this triggers a name collision warning in Go.

Context is a global concept. The exact same context object is passed through your custom middleware, the router, and any third-party libraries you might be using. If a third-party logging library also decides to store data under the exact string "userID", it will overwrite your authenticated UUID. Your downstream handler will suddenly receive a logging ID instead of a user ID, breaking the application.

To solve this elegantly, Go allows us to make context keys unique by defining a custom, unexported type based on a string.

In Go, contextKey("userID") is fundamentally different from the raw string "userID". Because contextKey is defined within our specific main package, the compiler treats it as its own distinct type. If a third-party library uses the exact same line of code, it generates a thirdParty.contextKey which the compiler knows is entirely distinct from our main.contextKey.

The Protected Dashboard

With the Go backend secured, here is how we hook everything up in the Next.js frontend to render the private data.

With this final piece, we successfully fetch the authenticated user's profile from the Go backend and render it securely on the screen.

Posts and Comments

With authentication done, I moved on to the forum itself. The first step was the database schema for posts and comments. I'll skip the routine parts and focus on optional filters.

I want posts to carry a category like environment or education, and I want users to be able to filter the feed by category, by author, or by both at once. The naive way to support that is to write one query per combination of filters:

The handler then has to branch through every combination to pick the right query:

This obviously doesn't scale. The number of branches grows as 2ⁿ, where n is the number of optional filters: two filters give you four branches, three filters give you eight. Every new filter doubles the surface area I have to write and maintain.

The way out is to stop writing a query per combination and instead write one query that treats each filter as optional. The standard SQL trick for that is an IS NULL OR guard in the WHERE clause: if the filter is NULL, the condition short-circuits to true and the row passes; if it has a value, the equality check applies.

This works, but it's ugly. The same category value has to be passed twice—once for the NULL check at $1 and once for the equality at $2—because positional parameters treat them as two independent inputs. That's an easy way to introduce a bug where the two drift apart.

sqlc has a cleaner answer: named parameters. Instead of positional $1 and $2, I can write @category in both spots, and sqlc understands they refer to the same input. Postgres still needs to know the type of that parameter, though—is it a NULL string or a NULL integer?—so I have to cast it to the enum type I declared for the category column:

Here is the enum, declared back when I created the posts schema:

So the SQL is clean now. But the moment I tried to actually pass "no category" from Go, a new problem surfaced. For a non-nullable parameter, sqlc generates a plain Go string field, and a Go string has no concept of nil—its zero value is the empty string "". So when the user applies no category filter, my handler sends "", and Postgres faithfully runs WHERE category = '', which matches nothing. The IS NULL branch never fires, because "" is a perfectly valid string, not NULL.

The fix is to make the parameter genuinely nullable, and sqlc exposes this through sqlc.narg()—the "n" stands for nullable. When I wrap the parameter in sqlc.narg('category'), sqlc generates a NullPostCategory struct instead of a bare string:

This is the same Valid bool pattern as the pgtype structs from earlier. Now my Go handler can send a real NULL by setting Valid to false, and Postgres recognizes it as NULL rather than an empty string—so the IS NULL branch finally does what I wanted. The final query:

Pagination with Offset, Limit and Cursor

In most cases, when an application needs to render a lot of data, I cannot simply render 1000 items in a single page, it is a bad UX and UI. So I implement paginations so that the frontend can render somewhere around 20 to 30 items in a single page. This is mainly achieved through the sql query with the OFFSET and LIMIT clauses.