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.