Zum Inhalt springen

Dec 20, 2024

Go + Next.js: Full-Stack Architecture for Modern Web Apps

11 min · Backend · Frontend · Go · Next.js · Architecture · TypeScript

Go + Next.js: Full-Stack Architecture for Modern Web Apps

The combination of Go on the backend and Next.js on the frontend has become the gold standard for building modern, scalable web applications. Go provides performance, concurrency, and operational simplicity. Next.js provides a superior developer experience and world-class performance on the frontend.

But making them work together seamlessly requires thoughtful API design, proper type sharing, and architectural patterns that embrace asynchronicity.

Why Go + Next.js?

Go's Strengths

  • Performance: Compiled to machine code, single binary deployment
  • Concurrency: Goroutines make handling thousands of concurrent requests simple
  • Simplicity: Minimal dependencies, explicit error handling, no magic
  • Operational: Single binary, no runtime required, easy to monitor

Next.js Strengths

  • Developer Experience: File-based routing, hot reload, excellent tooling
  • Performance: Built-in image optimization, code splitting, ISR
  • Type Safety: Full TypeScript support from client to server
  • Flexibility: Works as static site generator, server-side renderer, or SPA

Together, they let you build fast, maintainable applications at scale.

Architecture Overview

┌─────────────────────────────────────────────────┐
│         Next.js Application (TypeScript)         │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │  Frontend Components & Pages                │ │
│  │  - React with TypeScript                   │ │
│  │  - Tailwind CSS + Framer Motion           │ │
│  │  - Next.js App Router                     │ │
│  └────────────────────────────────────────────┘ │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │  API Client Layer                          │ │
│  │  - Typed fetch wrapper                    │ │
│  │  - Error handling                         │ │
│  │  - Request/response transformation       │ │
│  └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
              │
              │ JSON over HTTP/REST
              │
┌─────────────────────────────────────────────────┐
│          Go Application (http Server)            │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │  HTTP Handlers                             │ │
│  │  - Validate request                       │ │
│  │  - Call service logic                     │ │
│  │  - Return JSON response                   │ │
│  └────────────────────────────────────────────┘ │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │  Service Layer                             │ │
│  │  - Business logic                         │ │
│  │  - Orchestration                          │ │
│  │  - Error handling                         │ │
│  └────────────────────────────────────────────┘ │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │  Repository Layer                          │ │
│  │  - Database queries (GORM)                │ │
│  │  - Transactions                           │ │
│  │  - Caching (Redis)                        │ │
│  └────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────┘
              │
              │ SQL/Redis Protocol
              │
┌─────────────────────────────────────────────────┐
│       PostgreSQL + Redis                        │
└─────────────────────────────────────────────────┘

Type-Safe Communication Across the Stack

One of the biggest wins of Go + Next.js is leveraging TypeScript to maintain type safety from frontend to backend.

Shared Type Definition Pattern

Create a shared types package that both frontend and backend reference:

// shared/types/api.ts (included in both projects)
export namespace API {
  // Request/Response types
  export interface Product {
    id: string;
    name: string;
    description: string;
    price: number;
    createdAt: string;
    updatedAt: string;
  }

  export interface CreateProductRequest {
    name: string;
    description: string;
    price: number;
  }

  export interface UpdateProductRequest {
    name?: string;
    description?: string;
    price?: number;
  }

  export interface ApiResponse<T> {
    success: boolean;
    data?: T;
    error?: {
      code: string;
      message: string;
      details?: Record<string, unknown>;
    };
  }

  export interface ListResponse<T> {
    items: T[];
    total: number;
    page: number;
    pageSize: number;
  }
}

Backend Implementation (Go)

Generate Go types from TypeScript using tools like json-to-go:

// backend/pkg/models/product.go
package models

type Product struct {
    ID          string    `json:"id" gorm:"primaryKey"`
    Name        string    `json:"name" gorm:"index"`
    Description string    `json:"description"`
    Price       float64   `json:"price"`
    CreatedAt   time.Time `json:"createdAt"`
    UpdatedAt   time.Time `json:"updatedAt"`
}

type CreateProductRequest struct {
    Name        string  `json:"name" binding:"required"`
    Description string  `json:"description"`
    Price       float64 `json:"price" binding:"required,gt=0"`
}

type ApiResponse struct {
    Success bool        `json:"success"`
    Data    interface{} `json:"data,omitempty"`
    Error   *ErrorData  `json:"error,omitempty"`
}

type ErrorData struct {
    Code    string                 `json:"code"`
    Message string                 `json:"message"`
    Details map[string]interface{} `json:"details,omitempty"`
}

Frontend Implementation (TypeScript)

Use the shared types in your Next.js client:

// src/shared/api/client.ts
import type { API } from '@/shared/types/api';

export class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  async request<T>(
    endpoint: string,
    options?: RequestInit
  ): Promise<API.ApiResponse<T>> {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      ...options,
      headers: {
        'Content-Type': 'application/json',
        ...options?.headers,
      },
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.error?.message || 'Request failed');
    }

    return response.json();
  }

  async getProduct(id: string): Promise<API.Product> {
    const response = await this.request<API.Product>(`/api/v1/products/${id}`);
    if (!response.success || !response.data) {
      throw new Error('Failed to fetch product');
    }
    return response.data;
  }

  async createProduct(req: API.CreateProductRequest): Promise<API.Product> {
    const response = await this.request<API.Product>('/api/v1/products', {
      method: 'POST',
      body: JSON.stringify(req),
    });
    if (!response.success || !response.data) {
      throw new Error('Failed to create product');
    }
    return response.data;
  }
}

// Usage in components
export async function ProductForm() {
  const api = new ApiClient(process.env.NEXT_PUBLIC_API_URL!);

  async function handleSubmit(formData: API.CreateProductRequest) {
    try {
      const product = await api.createProduct(formData);
      console.log('Product created:', product);
    } catch (error) {
      console.error('Error:', error);
    }
  }

  return (
    // Form JSX
  );
}

Go Backend Structure: Handler → Service → Repository

A clean separation of concerns in Go follows this pattern:

Layer 1: HTTP Handlers

// backend/pkg/api/handlers/product.go
package handlers

import (
    "github.com/gin-gonic/gin"
    "myapp/pkg/models"
    "myapp/pkg/services"
)

type ProductHandler struct {
    service *services.ProductService
}

func (h *ProductHandler) CreateProduct(c *gin.Context) {
    // 1. Validate input
    var req models.CreateProductRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, models.ApiResponse{
            Success: false,
            Error: &models.ErrorData{
                Code:    "VALIDATION_ERROR",
                Message: err.Error(),
            },
        })
        return
    }

    // 2. Call service
    product, err := h.service.CreateProduct(c.Request.Context(), req)
    if err != nil {
        c.JSON(500, models.ApiResponse{
            Success: false,
            Error: &models.ErrorData{
                Code:    "INTERNAL_ERROR",
                Message: "Failed to create product",
            },
        })
        return
    }

    // 3. Return response
    c.JSON(201, models.ApiResponse{
        Success: true,
        Data:    product,
    })
}

func (h *ProductHandler) GetProduct(c *gin.Context) {
    id := c.Param("id")

    product, err := h.service.GetProduct(c.Request.Context(), id)
    if err != nil {
        c.JSON(404, models.ApiResponse{
            Success: false,
            Error: &models.ErrorData{
                Code:    "NOT_FOUND",
                Message: "Product not found",
            },
        })
        return
    }

    c.JSON(200, models.ApiResponse{
        Success: true,
        Data:    product,
    })
}

Layer 2: Services (Business Logic)

// backend/pkg/services/product.go
package services

import (
    "context"
    "errors"
    "myapp/pkg/models"
    "myapp/pkg/repositories"
)

type ProductService struct {
    repo *repositories.ProductRepository
}

func (s *ProductService) CreateProduct(
    ctx context.Context,
    req models.CreateProductRequest,
) (*models.Product, error) {
    // Validate business rules
    if req.Price < 0 {
        return nil, errors.New("price must be positive")
    }

    // Create product in repository
    product, err := s.repo.Create(ctx, models.Product{
        Name:        req.Name,
        Description: req.Description,
        Price:       req.Price,
    })
    if err != nil {
        return nil, err
    }

    // Could trigger side effects here
    // - Send email notification
    // - Queue job for search indexing
    // - Update cache
    s.invalidateCache(ctx, "products:list")

    return product, nil
}

func (s *ProductService) GetProduct(
    ctx context.Context,
    id string,
) (*models.Product, error) {
    return s.repo.GetByID(ctx, id)
}

Layer 3: Repository (Data Access)

// backend/pkg/repositories/product.go
package repositories

import (
    "context"
    "myapp/pkg/models"
    "gorm.io/gorm"
)

type ProductRepository struct {
    db *gorm.DB
}

func (r *ProductRepository) Create(
    ctx context.Context,
    product models.Product,
) (*models.Product, error) {
    if err := r.db.WithContext(ctx).Create(&product).Error; err != nil {
        return nil, err
    }
    return &product, nil
}

func (r *ProductRepository) GetByID(
    ctx context.Context,
    id string,
) (*models.Product, error) {
    product := &models.Product{}
    if err := r.db.WithContext(ctx).Where("id = ?", id).First(product).Error; err != nil {
        return nil, err
    }
    return product, nil
}

func (r *ProductRepository) List(
    ctx context.Context,
    page, pageSize int,
) ([]models.Product, int64, error) {
    var products []models.Product
    var total int64

    db := r.db.WithContext(ctx)

    if err := db.Model(&models.Product{}).Count(&total).Error; err != nil {
        return nil, 0, err
    }

    offset := (page - 1) * pageSize
    if err := db.
        Offset(offset).
        Limit(pageSize).
        Find(&products).Error; err != nil {
        return nil, 0, err
    }

    return products, total, nil
}

Handling Long-Running Operations

Some operations (AI processing, file uploads, complex reports) shouldn't block API responses. The Go + Next.js stack handles this with job queues:

Backend: Queue Job and Return Immediately

// backend/pkg/api/handlers/product.go
type GenerateReportRequest struct {
    ProductID string `json:"product_id"`
    Format    string `json:"format"`
}

func (h *ProductHandler) GenerateReport(c *gin.Context) {
    var req GenerateReportRequest
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"error": err.Error()})
        return
    }

    // Queue job in Redis
    job := models.Job{
        ID:     uuid.New().String(),
        Type:   "generate_report",
        Payload: req,
        Status: "pending",
    }

    if err := h.jobQueue.Enqueue(c.Request.Context(), job); err != nil {
        c.JSON(500, gin.H{"error": "Failed to queue job"})
        return
    }

    // Return immediately with job ID
    c.JSON(202, gin.H{
        "job_id": job.ID,
        "status": "processing",
    })
}

// Background worker processes jobs
func (s *ProductService) ProcessReportJob(ctx context.Context, job models.Job) error {
    // Long-running operation
    report := generateComplexReport(job.Payload.ProductID)

    // Store result
    s.reportStorage.Save(ctx, job.ID, report)

    // Update job status
    s.jobQueue.MarkComplete(ctx, job.ID)

    return nil
}

Frontend: Poll for Completion

// src/hooks/useAsyncJob.ts
import { useState, useEffect } from 'react';

export function useAsyncJob(jobId: string) {
  const [status, setStatus] = useState<'processing' | 'complete' | 'error'>('processing');
  const [result, setResult] = useState(null);

  useEffect(() => {
    const interval = setInterval(async () => {
      const response = await fetch(`/api/v1/jobs/${jobId}`);
      const data = await response.json();

      if (data.status === 'complete') {
        setResult(data.result);
        setStatus('complete');
        clearInterval(interval);
      } else if (data.status === 'error') {
        setStatus('error');
        clearInterval(interval);
      }
    }, 2000);

    return () => clearInterval(interval);
  }, [jobId]);

  return { status, result };
}

// Usage
function ReportGenerator() {
  const [jobId, setJobId] = useState<string | null>(null);
  const { status, result } = useAsyncJob(jobId || '');

  async function generateReport() {
    const response = await api.request('/api/v1/reports/generate', {
      method: 'POST',
      body: JSON.stringify({ format: 'pdf' }),
    });
    setJobId(response.data?.job_id);
  }

  return (
    <div>
      <button onClick={generateReport}>Generate Report</button>
      {status === 'processing' && <p>Processing...</p>}
      {status === 'complete' && <a href={result}>Download Report</a>}
    </div>
  );
}

Error Handling Strategy

Consistent error handling across the stack:

// backend/pkg/errors/errors.go
package errors

type Code string

const (
    CodeValidationError Code = "VALIDATION_ERROR"
    CodeNotFound        Code = "NOT_FOUND"
    CodeUnauthorized    Code = "UNAUTHORIZED"
    CodeConflict        Code = "CONFLICT"
    CodeInternalError   Code = "INTERNAL_ERROR"
)

type ApiError struct {
    Code    Code                   `json:"code"`
    Message string                 `json:"message"`
    Details map[string]interface{} `json:"details,omitempty"`
    Status  int                    `json:"status,omitempty"`
}

func (e *ApiError) Error() string {
    return e.Message
}

// Middleware to catch and format errors
func ErrorHandlerMiddleware(c *gin.Context) {
    c.Next()

    if len(c.Errors) > 0 {
        err := c.Errors[0]
        if apiErr, ok := err.Err.(*ApiError); ok {
            c.JSON(apiErr.Status, models.ApiResponse{
                Success: false,
                Error: &models.ErrorData{
                    Code:    string(apiErr.Code),
                    Message: apiErr.Message,
                    Details: apiErr.Details,
                },
            })
        } else {
            c.JSON(500, models.ApiResponse{
                Success: false,
                Error: &models.ErrorData{
                    Code:    string(CodeInternalError),
                    Message: "Internal server error",
                },
            })
        }
    }
}

Deployment and Scaling

Go Backend Deployment

# Dockerfile for Go backend
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server ./cmd/server

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]
# Build and push
docker build -t myapp-backend:latest .
docker push registry.example.com/myapp-backend:latest

# Deploy to production
kubectl apply -f deployment.yaml

Next.js Frontend Deployment

# Build
pnpm run build

# Deploy to Vercel (recommended)
vercel --prod

# Or deploy Docker image to your infrastructure
docker build -t myapp-frontend:latest .
docker push registry.example.com/myapp-frontend:latest

Monitoring and Observability

Go Backend Metrics

import "github.com/prometheus/client_golang/prometheus"

var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total HTTP requests",
        },
        []string{"method", "endpoint", "status"},
    )

    httpDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name: "http_request_duration_seconds",
            Help: "HTTP request duration in seconds",
        },
        []string{"method", "endpoint"},
    )
)

// Middleware
func MetricsMiddleware(c *gin.Context) {
    start := time.Now()

    c.Next()

    duration := time.Since(start).Seconds()
    httpDuration.WithLabelValues(
        c.Request.Method,
        c.Request.URL.Path,
    ).Observe(duration)

    httpRequestsTotal.WithLabelValues(
        c.Request.Method,
        c.Request.URL.Path,
        fmt.Sprintf("%d", c.Writer.Status()),
    ).Inc()
}

Frontend Performance Monitoring

// src/shared/lib/analytics.ts
export function reportWebVitals(metric: any) {
  const body = JSON.stringify(metric);
  navigator.sendBeacon('/api/v1/metrics', body);
}

// pages/_app.tsx
import { reportWebVitals } from '@/shared/lib/analytics';

export function reportWebVitals(metric: NextWebVitalsMetric) {
  reportWebVitals(metric);
}

Lessons Learned

1. Invest in Type Safety Early

Shared types prevent bugs and make refactoring safe. They're worth the initial setup cost.

2. Handle Async Properly

Don't block API responses on long operations. Use job queues.

3. Error Handling Matters

Consistent error formats across the stack make debugging vastly easier.

4. Monitor from Day One

Set up metrics and logging early. They're invaluable for production issues.

5. Clean Layering Pays Off

Handler → Service → Repository separation makes code testable and maintainable.

Conclusion

Go + Next.js is a powerful combination precisely because it lets you leverage the strengths of each:

  • Go: Performance, simplicity, concurrency, operational ease
  • Next.js: Developer experience, TypeScript, performance, flexibility

Combined with proper API design, type safety, and asynchronous patterns, you can build scalable, maintainable systems that serve millions of users.

The stack isn't just hype - it's a genuinely excellent choice for modern web applications.

MK