DEV Community

Cover image for How to Structure a Project(Next.js + Go)
Ryan Kikayi
Ryan Kikayi

Posted on

How to Structure a Project(Next.js + Go)

How I Structure a Next.js + Go Project (Frontend, Backend, and Deployment)

When you're building a full-stack app with Next.js on the frontend and Go on the backend, one of the first questions you'll face is: how do I organize this so it doesn't turn into a mess later?

I ran into this while building a school management system with Go, Next.js, and PostgreSQL — a project with several modules (students, finance, timetable, staff, and more). In this article, I'll walk through how I split the frontend and backend, how they talk to each other, and how I deploy both together.

1. One Repo or Two?

You have two options:

  • Monorepo — frontend and backend live in the same repository, in separate folders (e.g. /frontend and /backend).
  • Separate repos — each app has its own repository.

For a solo project or a small team, I prefer a monorepo. It's easier to keep frontend and backend changes in sync, and you only need to clone one project to get started. Separate repos make more sense once you have separate teams working independently, or when the frontend and backend have very different release schedules.

project/
├── backend/     (Go)
├── frontend/    (Next.js)
└── docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

2. Structuring the Backend (Go)

Instead of organizing your Go code by technical layer only (all handlers in one folder, all models in another), I organize it by feature/module first, then by layer inside each module. This matters a lot once your app has several modules.

backend/
├── cmd/
│   └── server/
│       └── main.go
├── internal/
│   ├── student/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── model.go
│   ├── finance/
│   │   ├── handler.go
│   │   ├── service.go
│   │   └── model.go
│   └── ...
├── pkg/
│   └── (shared utilities, e.g. auth, db, validation)
└── go.mod
Enter fullscreen mode Exit fullscreen mode

Why this works well:

  • Each module is self-contained. If you need to change how "Finance" works, you know exactly where to look.
  • It's easier to onboard someone new — they can understand one module without reading the whole codebase.
  • If a module ever needs to become its own service later, it's already mostly separated.

3. Structuring the Frontend (Next.js)

On the frontend, I follow a similar idea — group by feature, and keep a clear separation between UI, data-fetching, and shared code.

frontend/
├── app/
│   ├── students/
│   ├── finance/
│   └── ...
├── lib/
│   ├── api/
│   │   ├── client.ts
│   │   ├── students.ts
│   │   └── finance.ts
│   └── types/
│       └── (shared TypeScript types)
├── components/
└── package.json
Enter fullscreen mode Exit fullscreen mode

The lib/api/client.ts file is important — this is where all requests to the backend go through one place, instead of writing raw fetch calls all over the app.

4. How the Frontend and Backend Talk to Each Other

This is where a lot of things can go wrong if you're not careful, so here's what worked for me:

Keep a single API client. Instead of calling fetch() everywhere, wrap it in one function that adds the base URL, headers, and auth token automatically:

// lib/api/client.ts
const BASE_URL = process.env.NEXT_PUBLIC_API_URL;

export async function apiFetch(path: string, options: RequestInit = {}) {
  const token = getToken(); // however you store it

  const res = await fetch(`${BASE_URL}${path}`, {
    ...options,
    headers: {
      "Content-Type": "application/json",
      Authorization: token ? `Bearer ${token}` : "",
      ...options.headers,
    },
  });

  if (!res.ok) {
    throw new Error(`Request failed: ${res.status}`);
  }

  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Every module then builds on top of this — e.g. lib/api/students.ts calls apiFetch("/students").

Keep your types close to your API shape. In Go, define clear structs for your request/response bodies. On the frontend, write matching TypeScript types. It's manual work, but it saves you from a lot of confusing bugs where the frontend expects a field that the backend doesn't send.

Handle loading and error states the same way everywhere. With several modules, it's easy to end up with five different ways of showing "loading..." or an error message. Pick one pattern (a simple hook, or a wrapper component) and reuse it.

5. Local Development Setup

Running two apps at once can be annoying if it's not automated. I use docker-compose to start everything with one command:

version: "3"
services:
  backend:
    build: ./backend
    ports:
      - "8080:8080"
    env_file: ./backend/.env

  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    env_file: ./frontend/.env
    depends_on:
      - backend

  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
Enter fullscreen mode Exit fullscreen mode

For local dev, you also need CORS enabled on the Go backend so the frontend (running on a different port) can call it:

func corsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "http://localhost:3000")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
        next.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

In production, you'll usually avoid this problem entirely by putting both apps behind the same domain.

6. Deployment

There are two common ways to deploy a Next.js + Go project:

Option A: Same server, reverse proxy in front.
Both apps run on the same machine. A reverse proxy like Nginx or Caddy sits in front and routes traffic:

  • /api/* - Go backend
  • everything else - Next.js frontend

This avoids CORS issues completely and keeps things simple for small to mid-sized projects.

Option B: Separate hosting.
Frontend goes to a platform like Vercel, backend goes to a VM, container platform, or somewhere like Render/Fly.io. This gives you more flexibility to scale each part independently, but you'll need to handle CORS and manage two deployment pipelines instead of one.

For a project like the school management system, I went with Option A — one server, Go binary running behind Caddy, Next.js served alongside it. It kept the deployment simple, and simplicity mattered more than independent scaling at that stage.

7. What I'd Do Differently

If I started this project again, I'd generate the TypeScript types directly from the Go structs instead of writing them by hand on both sides. Keeping them in sync manually works, but it's an easy place to introduce bugs as the project grows. Tools like swaggo (for generating OpenAPI docs from Go) paired with an OpenAPI-to-TypeScript generator would save time and reduce mismatches.

Conclusion

Structuring a Next.js + Go project comes down to a few simple decisions: organize by feature on both sides, centralize how the frontend talks to the backend, and pick a deployment setup that matches the size of your project — not the size you imagine it might become.

If you're working on something similar, I'd love to hear how you've structured yours. Drop a comment below.

Top comments (0)