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
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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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)
// 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)
// 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
# 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:
- backendBackend 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 /app/main .
CMD ["./main"]Frontend 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 /app/.next ./.next
COPY /app/public ./public
COPY /app/package*.json ./
RUN npm ci --only=production
EXPOSE 3000
CMD ["npm", "start"]Best Practices
- API Versioning: Use
/api/v1/prefix - Error Handling: Consistent error response format
- Validation: Validate on both backend and frontend
- Security: Use HTTPS in production
- Caching: Implement Redis caching for frequently accessed data
- Monitoring: Add logging and monitoring
- 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