Building REST APIs with Go: A Full-Stack Journey

Lessons from building a blogging platform with Go backend, React frontend, and AI-powered features using Amazon Bedrock Claude.

5 min read
backendengineeringlearning

When I decided to build a portfolio with an integrated blogging system, I could have reached for the familiar: Node.js, Express, maybe throw in some Next.js API routes. Instead, I chose to build the backend in Go—a language I wanted to master. Here's what I learned building REST APIs in Go for a full-stack web application.

Why Go?

Go has been on my learning list for a while. The reasons became clear as I built:

  • Simplicity: Go's philosophy of "one way to do things" reduces decision fatigue
  • Performance: Compiled language with excellent concurrency primitives
  • Explicit error handling: Forces you to think about edge cases
  • Standard library: HTTP server built-in, minimal dependencies needed
💡Learning strategy

The best way to learn a language is to build something real with it. A portfolio project gave me the perfect excuse to go deep on Go.

The Architecture

The blogging platform has a clear separation of concerns:

┌─────────────────┐     ┌─────────────────┐     ┌─────────────────┐
│   React UI      │────▶│   Go Backend    │────▶│   Database      │
│   (Frontend)    │     │   (REST API)    │     │   (Storage)     │
└─────────────────┘     └────────┬────────┘     └─────────────────┘
                                 │
                                 ▼
                        ┌─────────────────┐
                        │  Amazon Bedrock │
                        │  (AI Features)  │
                        └─────────────────┘

The Go backend handles:

  • CRUD operations for blog posts
  • Draft/publish workflow management
  • Tag-based organization and search
  • Integration with Amazon Bedrock for AI features

Building the REST API

Handler Structure

Go's net/http package is surprisingly capable out of the box. Here's how I structured handlers:

// Post handler with proper error handling
func (h *PostHandler) CreatePost(w http.ResponseWriter, r *http.Request) {
    var input CreatePostInput
    if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
        h.respondError(w, http.StatusBadRequest, "Invalid request body")
        return
    }

    if err := h.validate(&input); err != nil {
        h.respondError(w, http.StatusBadRequest, err.Error())
        return
    }

    post, err := h.service.CreatePost(r.Context(), input)
    if err != nil {
        h.handleServiceError(w, err)
        return
    }

    h.respondJSON(w, http.StatusCreated, post)
}

Consistent Error Responses

One pattern I found essential: structured error responses. Every error returns the same shape:

type ErrorResponse struct {
    Error   string `json:"error"`
    Code    string `json:"code,omitempty"`
    Details any    `json:"details,omitempty"`
}

func (h *Handler) respondError(w http.ResponseWriter, status int, message string) {
    h.respondJSON(w, status, ErrorResponse{Error: message})
}

This makes the API predictable for frontend consumers—you always know what error responses look like.

Input Validation

Go doesn't have built-in validation like some frameworks, but that's actually a feature. It forces you to be explicit:

func (h *PostHandler) validate(input *CreatePostInput) error {
    if strings.TrimSpace(input.Title) == "" {
        return errors.New("title is required")
    }
    if len(input.Title) > 200 {
        return errors.New("title must be 200 characters or less")
    }
    if strings.TrimSpace(input.Content) == "" {
        return errors.New("content is required")
    }
    return nil
}

AI-Powered Tag Generation

The most interesting feature was integrating Amazon Bedrock Claude for automatic tag generation. When you save a draft, the system can suggest relevant tags based on the content.

func (s *TagService) GenerateTags(ctx context.Context, content string) ([]string, error) {
    prompt := buildTagPrompt(content)
    
    response, err := s.bedrock.InvokeModel(ctx, &bedrockruntime.InvokeModelInput{
        ModelId: aws.String("anthropic.claude-v2"),
        Body:    []byte(prompt),
    })
    if err != nil {
        return nil, fmt.Errorf("bedrock invocation failed: %w", err)
    }

    tags := parseTagResponse(response.Body)
    return s.filterAndValidate(tags), nil
}
⚠️AI integration tip

Always add fallback logic when integrating AI services. Network issues, rate limits, and unexpected responses happen. Your app should degrade gracefully.

Prompt Engineering for Tags

Getting useful tags required careful prompt design:

func buildTagPrompt(content string) string {
    return fmt.Sprintf(`Analyze this blog post and suggest 3-5 relevant tags.

Rules:
- Tags should be lowercase, single words or short phrases
- Focus on technical topics and themes
- Avoid generic tags like "blog" or "post"
- Return only the tags as a JSON array

Content:
%s

Tags:`, content)
}

The prompt constraints were essential—without them, Claude would sometimes return entire sentences or overly generic suggestions.

Draft/Publish Workflow

Managing content state required careful design. Posts can be:

  • Draft: In progress, not visible publicly
  • Published: Live and visible
  • Archived: Hidden but preserved
type PostStatus string

const (
    StatusDraft     PostStatus = "draft"
    StatusPublished PostStatus = "published"
    StatusArchived  PostStatus = "archived"
)

func (p *Post) CanPublish() bool {
    return p.Status == StatusDraft && p.Title != "" && p.Content != ""
}

func (p *Post) Publish() error {
    if !p.CanPublish() {
        return ErrCannotPublish
    }
    p.Status = StatusPublished
    p.PublishedAt = time.Now()
    return nil
}

Lessons Learned

1. Go's Simplicity Is a Feature

At first, Go felt limiting. No generics (at the time), no inheritance, explicit error handling everywhere. But these "limitations" actually made the code clearer and more maintainable.

2. Environment-Based Configuration Matters

Having different configs for development, staging, and production saved countless debugging hours:

type Config struct {
    Port        string
    DatabaseURL string
    BedrockRegion string
    LogLevel    string
}

func LoadConfig() *Config {
    return &Config{
        Port:          getEnv("PORT", "8080"),
        DatabaseURL:   getEnv("DATABASE_URL", "sqlite:./dev.db"),
        BedrockRegion: getEnv("AWS_REGION", "us-east-1"),
        LogLevel:      getEnv("LOG_LEVEL", "info"),
    }
}

3. Structured Logging Is Non-Negotiable

When something goes wrong in production, you need to know exactly what happened. Structured logging made debugging possible:

log.Info("post created",
    "post_id", post.ID,
    "author", post.AuthorID,
    "has_tags", len(post.Tags) > 0,
)

4. The React Frontend Integration

Keeping the frontend happy meant:

  • Consistent response shapes
  • Proper CORS configuration
  • Clear error messages that could be displayed to users

What I'd Do Differently

If I were starting over:

  1. Use a router library earlier: Go's standard mux is fine, but chi or gorilla/mux add useful features
  2. Set up structured logging from day one: Retrofitting is painful
  3. Write API documentation in parallel: OpenAPI spec would have helped

Conclusion

Building REST APIs in Go was a valuable learning experience. The language forces you to think carefully about error handling, keeps your code explicit and readable, and performs excellently.

For anyone considering Go for their next backend: do it. The initial learning curve pays dividends in maintainability and performance.


Building APIs in Go or exploring backend development? Connect on LinkedIn—always happy to discuss architecture and design patterns.