Building Offloadr: A Student Marketplace with Go and Next.js

When I set out to build Offloadr, I wanted to create something that would genuinely help students. The idea was simple: a platform where students could easily sell items they no longer needed before graduating or moving out.
The Tech Stack
I chose Go for the backend because of its excellent performance characteristics and straightforward concurrency model. For the frontend, Next.js was an obvious choice given its excellent developer experience and built-in optimizations.
Go's simplicity and performance made it perfect for building a fast, reliable API that could handle thousands of concurrent requests.
Backend Architecture
The Go backend uses the Chi router for its lightweight and composable approach to routing. Here's a simplified look at the project structure:
// main.go
package main
import (
"log"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
// Middleware
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
// Routes
r.Route("/api/v1", func(r chi.Router) {
r.Mount("/items", itemRoutes())
r.Mount("/users", userRoutes())
r.Mount("/auth", authRoutes())
})
log.Println("Server starting on :8080")
http.ListenAndServe(":8080", r)
}
Database Design
PostgreSQL was chosen for its reliability and excellent support for complex queries. Here's a simplified schema:
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
school_id UUID REFERENCES schools(id),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seller_id UUID REFERENCES users(id),
title VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10, 2) NOT NULL,
status VARCHAR(50) DEFAULT 'published',
created_at TIMESTAMP DEFAULT NOW()
);
Frontend with Next.js
The frontend leverages Next.js 15's app router and server components for optimal performance:
// app/items/page.tsx
import { getItems } from "@/lib/api";
import { ItemCard } from "@/components/ItemCard";
export default async function ItemsPage() {
const items = await getItems();
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{items.map((item) => (
<ItemCard key={item.id} item={item} />
))}
</div>
);
}
Challenges & Solutions
1. Multi-Photo Uploads
Implementing drag-and-drop photo reordering while maintaining upload progress was tricky.
File uploads can be slow on poor connections. Always show progress indicators and allow retries.
2. Caching Strategy
With potentially thousands of listings, caching became essential. Redis handles:
- Session management
- Rate limiting
- Frequently accessed listings
- Search result caching
// Simple Redis caching example
func (s *ItemService) GetItem(id string) (*Item, error) {
// Try cache first
cached, err := s.redis.Get(ctx, "item:"+id).Result()
if err == nil {
var item Item
json.Unmarshal([]byte(cached), &item)
return &item, nil
}
// Fallback to database
item, err := s.repo.FindByID(id)
if err != nil {
return nil, err
}
// Cache for 5 minutes
s.redis.Set(ctx, "item:"+id, item, 5*time.Minute)
return item, nil
}
Lessons Learned
- Start with a solid foundation - The time invested in proper architecture paid off
- Cache aggressively - Database queries are expensive
- Handle errors gracefully - Users should never see technical error messages
- Test on slow connections - Not everyone has fast internet
What's Next
I'm planning to add real-time notifications using WebSockets and expand the platform to more schools. Stay tuned for updates!
Have questions about Offloadr or want to contribute? Check out the GitHub repo.