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.
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
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
}
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:
- Use a router library earlier: Go's standard mux is fine, but chi or gorilla/mux add useful features
- Set up structured logging from day one: Retrofitting is painful
- 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.