Back to all posts
June 3, 2025Charlie BrownArchitecture

Full-Stack Architecture: Golang Backend with Next.js Frontend

Learn how to build a complete full-stack application using Golang for the backend API and Next.js for the frontend, including authentication, API design, and deployment strategies.

Full-Stack Architecture: Golang Backend with Next.js Frontend

Full-Stack Architecture: Golang Backend with Next.js Frontend

Combining Golang's performance with Next.js's developer experience creates a powerful full-stack architecture. This article explores how to build a complete application using Golang for the backend API and Next.js 16 for the frontend, covering API design, authentication, real-time features, and deployment.

Architecture Overview

Technology Stack

Backend (Golang):

  • Gin framework for HTTP routing
  • GORM for database operations
  • JWT for authentication
  • PostgreSQL for data storage
  • Redis for caching and sessions

Frontend (Next.js):

  • Next.js 16 App Router
  • TypeScript for type safety
  • TanStack Query for data fetching
  • Tailwind CSS for styling
  • React Hook Form for forms

Backend Setup

Project Structure

backend/ ├── cmd/ │ └── api/ │ └── main.go ├── internal/ │ ├── handlers/ │ ├── services/ │ ├── repositories/ │ ├── models/ │ └── middleware/ ├── pkg/ │ ├── auth/ │ └── database/ ├── config/ └── migrations/

Main Application

go
// cmd/api/main.go
package main

import (
    "log"
    "github.com/gin-gonic/gin"
    "github.com/gin-contrib/cors"
    "yourproject/internal/handlers"
    "yourproject/pkg/database"
    "yourproject/pkg/auth"
)

func main() {
    // Initialize database
    db, err := database.InitDB()
    if err != nil {
        log.Fatal("Failed to connect to database:", err)
    }

    // Setup Gin router
    r := gin.Default()

    // CORS middleware
    r.Use(cors.New(cors.Config{
        AllowOrigins:     []string{"http://localhost:3000"},
        AllowMethods:     []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowHeaders:     []string{"Origin", "Content-Type", "Authorization"},
        ExposeHeaders:    []string{"Content-Length"},
        AllowCredentials: true,
    }))

    // Routes
    api := r.Group("/api/v1")
    {
        // Public routes
        auth := api.Group("/auth")
        {
            auth.POST("/register", handlers.Register)
            auth.POST("/login", handlers.Login)
            auth.POST("/refresh", handlers.RefreshToken)
        }

        // Protected routes
        protected := api.Group("")
        protected.Use(auth.AuthMiddleware())
        {
            users := protected.Group("/users")
            {
                users.GET("", handlers.GetUsers)
                users.GET("/:id", handlers.GetUser)
                users.PUT("/:id", handlers.UpdateUser)
            }

            posts := protected.Group("/posts")
            {
                posts.GET("", handlers.GetPosts)
                posts.POST("", handlers.CreatePost)
                posts.PUT("/:id", handlers.UpdatePost)
                posts.DELETE("/:id", handlers.DeletePost)
            }
        }
    }

    // Start server
    r.Run(":8080")
}

API Design

RESTful Endpoints

go
// internal/handlers/users.go
package handlers

import (
    "net/http"
    "strconv"
    "github.com/gin-gonic/gin"
    "yourproject/internal/services"
)

type UserHandler struct {
    userService *services.UserService
}

func NewUserHandler(userService *services.UserService) *UserHandler {
    return &UserHandler{userService: userService}
}

func (h *UserHandler) GetUsers(c *gin.Context) {
    page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
    limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))

    users, total, err := h.userService.GetUsers(page, limit)
    if err != nil {
        c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
        return
    }

    c.JSON(http.StatusOK, gin.H{
        "data": users,
        "pagination": gin.H{
            "page":  page,
            "limit": limit,
            "total": total,
        },
    })
}

func (h *UserHandler) GetUser(c *gin.Context) {
    id := c.Param("id")
    
    user, err := h.userService.GetUserByID(id)
    if err != nil {
        c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
        return
    }

    c.JSON(http.StatusOK, gin.H{"data": user})
}

Request/Response DTOs

go
// internal/models/dto.go
package models

type CreateUserRequest struct {
    FirstName string `json:"first_name" binding:"required,min=2"`
    LastName  string `json:"last_name" binding:"required,min=2"`
    Email     string `json:"email" binding:"required,email"`
    Password  string `json:"password" binding:"required,min=8"`
}

type UserResponse struct {
    ID        uint   `json:"id"`
    FirstName string `json:"first_name"`
    LastName  string `json:"last_name"`
    Email     string `json:"email"`
    CreatedAt string `json:"created_at"`
}

type LoginRequest struct {
    Email    string `json:"email" binding:"required,email"`
    Password string `json:"password" binding:"required"`
}

type LoginResponse struct {
    AccessToken  string       `json:"access_token"`
    RefreshToken string       `json:"refresh_token"`
    User         UserResponse `json:"user"`
}

Authentication

JWT Implementation

go
// pkg/auth/jwt.go
package auth

import (
    "errors"
    "time"
    "github.com/golang-jwt/jwt/v5"
)

var jwtSecret = []byte("your-secret-key")

type Claims struct {
    UserID uint   `json:"user_id"`
    Email  string `json:"email"`
    jwt.RegisteredClaims
}

func GenerateToken(userID uint, email string) (string, error) {
    claims := Claims{
        UserID: userID,
        Email:  email,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString(jwtSecret)
}

func ValidateToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return jwtSecret, nil
    })

    if err != nil {
        return nil, err
    }

    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    }

    return nil, errors.New("invalid token")
}

Authentication Middleware

go
// pkg/auth/middleware.go
package auth

import (
    "net/http"
    "strings"
    "github.com/gin-gonic/gin"
)

func AuthMiddleware() gin.HandlerFunc {
    return func(c *gin.Context) {
        authHeader := c.GetHeader("Authorization")
        
        if authHeader == "" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header required"})
            c.Abort()
            return
        }

        parts := strings.Split(authHeader, " ")
        if len(parts) != 2 || parts[0] != "Bearer" {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header"})
            c.Abort()
            return
        }

        claims, err := ValidateToken(parts[1])
        if err != nil {
            c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
            c.Abort()
            return
        }

        c.Set("userID", claims.UserID)
        c.Set("email", claims.Email)
        c.Next()
    }
}

Frontend Setup

API Client

typescript
// lib/api/client.ts
import axios, { AxiosInstance } from 'axios';

const apiClient: AxiosInstance = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080/api/v1',
  headers: {
    'Content-Type': 'application/json',
  },
});

// Request interceptor for auth token
apiClient.interceptors.request.use((config) => {
  const token = localStorage.getItem('access_token');
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

// Response interceptor for token refresh
apiClient.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const refreshToken = localStorage.getItem('refresh_token');
        const response = await axios.post(
          `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh`,
          { refresh_token: refreshToken }
        );

        const { access_token } = response.data;
        localStorage.setItem('access_token', access_token);

        originalRequest.headers.Authorization = `Bearer ${access_token}`;
        return apiClient(originalRequest);
      } catch (refreshError) {
        localStorage.removeItem('access_token');
        localStorage.removeItem('refresh_token');
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

export default apiClient;

API Hooks

typescript
// lib/api/hooks.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import apiClient from './client';

export function useUsers(page: number = 1, limit: number = 10) {
  return useQuery({
    queryKey: ['users', page, limit],
    queryFn: async () => {
      const response = await apiClient.get('/users', {
        params: { page, limit },
      });
      return response.data;
    },
  });
}

export function useUser(id: string) {
  return useQuery({
    queryKey: ['user', id],
    queryFn: async () => {
      const response = await apiClient.get(`/users/${id}`);
      return response.data;
    },
    enabled: !!id,
  });
}

export function useCreateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (userData: CreateUserRequest) => {
      const response = await apiClient.post('/users', userData);
      return response.data;
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
    },
  });
}

Server Components

typescript
// app/users/page.tsx
import { Suspense } from 'react';
import { UsersList } from '@/components/UsersList';
import { getUsers } from '@/lib/api/server';

async function UsersPage({
  searchParams,
}: {
  searchParams: { page?: string };
}) {
  const page = parseInt(searchParams.page || '1');
  const users = await getUsers(page);

  return (
    <div>
      <h1>Users</h1>
      <Suspense fallback={<div>Loading...</div>}>
        <UsersList initialData={users} />
      </Suspense>
    </div>
  );
}

export default UsersPage;

Real-time Features

WebSocket Server (Golang)

go
// pkg/websocket/server.go
package websocket

import (
    "github.com/gorilla/websocket"
    "github.com/gin-gonic/gin"
)

var upgrader = websocket.Upgrader{
    CheckOrigin: func(r *http.Request) bool {
        return true
    },
}

func HandleWebSocket(c *gin.Context) {
    conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
    if err != nil {
        return
    }
    defer conn.Close()

    for {
        messageType, message, err := conn.ReadMessage()
        if err != nil {
            break
        }

        // Echo message back
        conn.WriteMessage(messageType, message)
    }
}

WebSocket Client (Next.js)

typescript
// hooks/useWebSocket.ts
import { useEffect, useRef, useState } from 'react';

export function useWebSocket(url: string) {
  const [messages, setMessages] = useState<string[]>([]);
  const ws = useRef<WebSocket | null>(null);

  useEffect(() => {
    ws.current = new WebSocket(url);

    ws.current.onmessage = (event) => {
      setMessages((prev) => [...prev, event.data]);
    };

    return () => {
      ws.current?.close();
    };
  }, [url]);

  const sendMessage = (message: string) => {
    ws.current?.send(message);
  };

  return { messages, sendMessage };
}

Deployment

Docker Compose

yaml
# docker-compose.yml
version: '3.8'

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"

  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  backend:
    build: ./backend
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://postgres:postgres@postgres:5432/myapp
      REDIS_URL: redis://redis:6379
    depends_on:
      - postgres
      - redis

  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    environment:
      NEXT_PUBLIC_API_URL: http://localhost:8080/api/v1
    depends_on:
      - backend

Backend Dockerfile

dockerfile
# backend/Dockerfile
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o main ./cmd/api

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/

COPY --from=builder /app/main .
CMD ["./main"]

Frontend Dockerfile

dockerfile
# frontend/Dockerfile
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production

EXPOSE 3000
CMD ["npm", "start"]

Best Practices

  1. API Versioning: Use /api/v1/ prefix
  2. Error Handling: Consistent error response format
  3. Validation: Validate on both backend and frontend
  4. Security: Use HTTPS in production
  5. Caching: Implement Redis caching for frequently accessed data
  6. Monitoring: Add logging and monitoring
  7. Testing: Write tests for both backend and frontend

Conclusion

Building a full-stack application with Golang and Next.js combines performance with developer experience. By following RESTful API design, implementing proper authentication, and using modern frontend patterns, you can create scalable, maintainable applications. The key is to leverage each technology's strengths while maintaining clean separation of concerns.

References

Want more insights?

Subscribe to our newsletter or follow us for more updates on software development and team scaling.

Contact Us