Friend Inventory Tracker in Go
Build a Go web app to track friends' items and borrowing status.
Introduction
30 Second Summary
Think about how often you need something you don't own. A drill, a camping tent, a stand mixer. You know a friend has one somewhere, but you can never remember who.
In this project, you will build a Go web application that catalogs what your friends own, so you can instantly look up who to borrow from and whether the item is available.
What You'll Build
Type "vacuum" into the search bar and instantly see which friend owns one, whether it's available right now, and toggle its status when you borrow or return it.
By the end of this project, you'll have:
- A live search that filters across all your friends' items to find exactly what you need to borrow.
- Availability badges showing at a glance whether an item is free to borrow or currently lent out, with the ability to toggle status on demand.
- A full REST API backing the web interface, so you can also manage friends and items with curl or any HTTP client.
- Secret Mission: Add item categories and a full borrowing/return flow with timestamps.
Are there any prerequisites?
You need programming experience in any language (Java, Python, or React are all fine). No prior Go experience is required. This project teaches Go from scratch.
Before We Start
Before any code hits the screen, let's lock in what you're about to build and why it matters to you personally.
Set Up the Project
Every Go project starts with a module. A module is a declaration file that tells Go what your project is called and what external packages it depends on.
In this step, you'll create the project folder, initialize a Go module, install the SQLite database driver, and get a basic HTTP server responding to requests.
In this step, get ready to:
- Create the project directory and initialize a Go module.
- Install the SQLite database driver.
- Start a minimal HTTP server and confirm it responds in the browser.
Create the project directory and initialize the Go module
Go needs a module file to track your project's name and dependencies. You'll create a dedicated folder for the project first, then initialize the module inside it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir borrowbuddy
cd borrowbuddy
- Initialize a Go module by running:
go mod init borrowbuddy
- Confirm the module was created by running:
ls
You should see go.mod listed in the output.
What is go.mod?
The go.mod file is like a manifest for your project. It records your module's name (borrowbuddy) and will automatically track any external packages you install.
Seeing 'go: command not found'?
This means Go is not in your system's PATH. Verify your Go installation is correct by checking that go version prints a version number.
help me fix 'go: command not found' on macOS
Install the SQLite driver and create a minimal server
Your app will store data in a SQLite database. The modernc.org/sqlite package is a pure-Go driver that requires no extra C compiler setup.
- Install the SQLite driver by running:
go get modernc.org/sqlite
- Confirm the dependency was recorded by running:
ls
You should now see both go.mod and go.sum in the output.
What is go.sum?
The go.sum file contains checksums for every package Go downloaded. It ensures your dependencies haven't been tampered with between installs.
Download taking a long time?
The modernc.org/sqlite package has several transitive dependencies. On a slow connection the first download can take a minute. If it hangs entirely, check your internet connection and retry.
help me troubleshoot go get hanging
Now you'll create the entry point for your application. This file will start an HTTP server with a single route that proves everything is wired up.
- In VS Code, open the borrowbuddy folder on your Desktop.
- Create a new file called main.go in the root of that folder.
- Paste the following code into main.go:
package main
import (
"fmt"
"log"
"net/http"
// Register the SQLite driver for later use
_ "modernc.org/sqlite"
)
func main() {
// Create a new request multiplexer (router)
mux := http.NewServeMux()
// Handle GET requests to the exact root path
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "BorrowBuddy is running")
})
fmt.Println("BorrowBuddy server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
What does this code do?
- _ "modernc.org/sqlite" is a blank import. It registers the SQLite driver with Go's database/sql package without you calling it directly.
- http.NewServeMux() creates a router that maps URL patterns to handler functions.
- "GET /{$}" matches only GET requests to the exact root path /. The {$} suffix prevents the route from matching everything.
- log.Fatal(http.ListenAndServe(...)) starts the server and exits with an error if it ever stops.
- Save main.go.
Run the server and confirm it responds
Time to see if everything works. You'll start the server and check that it responds both in the terminal and the browser.
Before you run this, what do you expect to see printed in the terminal?
- Start the server by running:
go run main.go
You should see BorrowBuddy server starting on :8080 printed in the terminal. The cursor won't return to a prompt because the server is running and waiting for requests.
- Open your browser and navigate to http://localhost:8080.
You should see the text BorrowBuddy is running displayed on the page.
Seeing 'address already in use'?
Another process is already using port 8080. Find and stop it, or close any other terminal sessions that might be running a server. On macOS, run lsof -i :8080 to identify the process.
help me fix port 8080 already in use on macOS
Stopping the server
Press Ctrl+C in the terminal to stop the server when you're ready to make changes. You'll restart it throughout the project.
✔️ Awesome, I've got everything!
Great. Make sure you've saved main.go and confirmed the server responds in your browser.
ⓧ I'd like to double check the full code
Here is the complete main.go for this step:
package main
import (
"fmt"
"log"
"net/http"
_ "modernc.org/sqlite"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "BorrowBuddy is running")
})
fmt.Println("BorrowBuddy server starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Your server is alive and responding. Next up, you'll define the data model and connect to a real SQLite database so the app can actually store information.
Define the Data Model and Database
Your server runs and responds, but it forgets everything the moment you stop it. Without a database, there is no way to store friends or their items between sessions.
In this step, you will define Go structs that represent your data and connect to a SQLite database that persists everything to disk. By the end, your app will create its own database file automatically on startup.
In this step, get ready to:
- Define Go structs for Friend and Item with JSON tags.
- Write an initDB function that opens SQLite and creates tables.
- Call initDB from main and verify the database file is created.
Define the Friend and Item structs
Every friend has a name, and every item has a name, an owner, and an availability status. Go uses structs to describe these shapes, and JSON tags tell the server how to format them for API responses later.
- In main.go, find the import block at the top of the file:
import (
"fmt"
"log"
"net/http"
_ "modernc.org/sqlite"
)
- Add "database/sql" as the first import inside the parentheses, so the block looks like this:
import (
"database/sql"
"fmt"
"log"
"net/http"
_ "modernc.org/sqlite"
)
- Below the closing ) of your import block and above func main(), add the following code:
var db *sql.DB
type Friend struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
type Item struct {
ID int64 `json:"id"`
FriendID int64 `json:"friend_id"`
FriendName string `json:"friend_name"`
Name string `json:"name"`
Available bool `json:"available"`
}
What does this code do?
var db *sql.DB declares a package-level variable that holds your database connection. Every handler in the app will use this single connection to read and write data.
The Friend struct has an ID and a Name. The Item struct tracks which friend owns it (FriendID), its name, and whether it is currently available to borrow.
The backtick tags like `json:"friend_id"` tell Go's JSON encoder what key name to use when converting the struct to JSON output.
Write the initDB function
This function opens a connection to SQLite and creates the tables your app needs. If the database file does not exist yet, SQLite creates it automatically.
- In main.go, add the following function between your struct definitions and func main():
func initDB() error {
var err error
db, err = sql.Open("sqlite", "file:borrowbuddy.db?_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_foreign_keys=ON")
if err != nil {
return err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS friends (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
friend_id INTEGER NOT NULL,
name TEXT NOT NULL,
available BOOLEAN NOT NULL DEFAULT 1,
FOREIGN KEY (friend_id) REFERENCES friends(id)
);
`)
if err != nil {
return err
}
log.Println("Database initialized")
return nil
}
What does this code do?
sql.Open("sqlite", ...) opens a connection to the SQLite database file borrowbuddy.db. The connection string enables WAL mode for better concurrency and turns on foreign key enforcement.
SetMaxOpenConns(1) and SetMaxIdleConns(1) limit the pool to a single connection. SQLite only supports one writer at a time, so this avoids lock contention.
The db.Exec call creates two tables: friends (with an auto-incrementing ID and a name) and items (linked to a friend via a foreign key, with a boolean tracking availability).
- Save main.go.
- Confirm the code compiles by running:
go build .
If compilation succeeds, you will see no error output and a borrowbuddy binary appears in your folder.
Seeing a compilation error?
- Check that "database/sql" appears inside your import block. Without it, Go cannot find the sql.DB type.
- Make sure the backtick tags on each struct field use backticks (`), not regular quotes.
- Verify the function signature is func initDB() error with a capital D and B.
Still stuck? Help me fix my Go compilation error in main.go
Call initDB from main and verify the database
The initDB function exists but nothing calls it yet. You need to invoke it at the very start of main() so the database is ready before any requests arrive.
- In main.go, find the opening of your main function. It currently looks like this:
func main() {
mux := http.NewServeMux()
- Replace those two lines with the following, which calls initDB first and defers closing the database connection:
func main() {
if err := initDB(); err != nil {
log.Fatal("Failed to initialize database:", err)
}
defer db.Close()
mux := http.NewServeMux()
What does this change do?
initDB() runs before anything else in main. If it returns an error, log.Fatal prints the error and stops the program immediately.
defer db.Close() ensures the database connection is properly closed when the program exits. The rest of main (the router and server) remains unchanged.
- Save main.go.
Before you run the server, what do you think will appear in the terminal output this time?
- Start the server by running:
go run main.go
You should see two lines in your terminal:
2026/07/14 12:00:00 Database initialized
BorrowBuddy server starting on :8080
The timestamp will differ, but the messages confirm your database connected and the server started.
- Stop the server with Ctrl+C.
- Confirm the database file was created by running:
ls borrowbuddy.db
You should see borrowbuddy.db printed. SQLite created this file automatically when initDB ran.
Don't see the database file?
- Make sure you ran go run main.go from inside the borrowbuddy folder. The file is created relative to where you run the command.
- If you see "Failed to initialize database" in the output, check that your sql.Open line uses "sqlite" as the driver name (not "sqlite3").
- Verify the blank import _ "modernc.org/sqlite" is still in your import block. This registers the driver.
Help me debug why my borrowbuddy.db file is not being created
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and that borrowbuddy.db exists in your project folder.
ⓧ I'd like to double check the full code
Scroll up and confirm your main.go contains each of these pieces in order:
- The import block including "database/sql" and _ "modernc.org/sqlite".
- var db *sql.DB below the imports.
- The Friend and Item struct definitions with JSON tags.
- The initDB() function that opens SQLite, sets connection limits, and creates both tables.
- func main() calling initDB() first, then defer db.Close(), then the router and server.
Your app now has a real data layer. Next up, you will build the REST API endpoints that let you create friends, add items, and query everything through HTTP.
Build the REST API Endpoints
Your database is ready and your tables are waiting for data. Now it's time to build the endpoints that actually let you create friends, add items, and query everything back.
The API is the backbone of BorrowBuddy. Without it, the web interface you'll build later has nothing to display. In this step, you'll use Go's net/http package to handle JSON requests and responses through a REST API.
In this step, get ready to:
- Implement POST /api/friends and GET /api/friends endpoints.
- Implement POST /api/items and GET /api/items endpoints with a shared query helper.
- Implement PATCH /api/items/{id}/toggle to flip availability status.
Implement the friends endpoints
Every endpoint needs two things: a handler function that processes the request, and a route that maps a URL pattern to that handler. You'll start with friends since items depend on them.
- In main.go, find your import block at the top of the file.
- Find this section:
import (
"database/sql"
"fmt"
"log"
"net/http"
_ "modernc.org/sqlite"
)
- Replace it with this updated import block that adds encoding/json and strings:
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
_ "modernc.org/sqlite"
)
What are these new imports for?
- encoding/json lets you encode Go structs into JSON responses and decode JSON request bodies into structs.
- strings provides TrimSpace for validating that user input is not blank.
- Write the handleGetFriends function below your main() function by pasting this code:
func handleGetFriends(w http.ResponseWriter, r *http.Request) {
// Query all friends from the database
rows, err := db.Query("SELECT id, name FROM friends ORDER BY name")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
// Build a slice of Friend structs from the rows
var friends []Friend
for rows.Next() {
var f Friend
if err := rows.Scan(&f.ID, &f.Name); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
friends = append(friends, f)
}
// Send the friends back as JSON
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(friends)
}
What does this code do?
- db.Query runs the SQL and returns rows. defer rows.Close() ensures the database connection is released after the function finishes.
- The for rows.Next() loop iterates through each row, scanning values into a Friend struct.
- json.NewEncoder(w).Encode(friends) writes the slice as a JSON array directly into the HTTP response.
- Now find the line mux := http.NewServeMux() inside your main() function.
- Add this route registration directly below it:
mux.HandleFunc("GET /api/friends", handleGetFriends)
What is Go 1.22+ method routing?
The pattern "GET /api/friends" tells Go's ServeMux to only match GET requests to that exact path. If someone sends a POST to the same URL, Go automatically returns 405 Method Not Allowed.
Before Go 1.22, you had to check the method inside every handler yourself or use a third-party router. This built-in pattern matching is why the project uses Go 1.26.
- Save main.go.
- Stop your running server with Ctrl+C in the terminal, then restart it by running:
go run main.go
- Test the GET friends endpoint by running this in a new terminal tab:
curl http://localhost:8080/api/friends
You should see null as the response. That is correct. The friends table is empty, so the slice is nil and encodes as null.
Seeing a compile error?
- If you see imported and not used, make sure both encoding/json and strings are in the import block. Go won't compile with unused imports, but strings will be used in the next handler.
- If you see undefined: handleGetFriends, confirm the function is outside main() (below the closing brace), not nested inside it.
Help me fix a compile error in my Go REST API handler.
Now you need a way to actually add friends. The POST handler decodes JSON from the request body, validates it, and inserts a row.
- Add the handleCreateFriend function below handleGetFriends by pasting this code:
func handleCreateFriend(w http.ResponseWriter, r *http.Request) {
// Decode the JSON body into a Friend struct
var f Friend
if err := json.NewDecoder(r.Body).Decode(&f); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validate that the name is not empty
if strings.TrimSpace(f.Name) == "" {
http.Error(w, "Name is required", http.StatusBadRequest)
return
}
// Insert the friend into the database
result, err := db.Exec("INSERT INTO friends (name) VALUES (?)", f.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Return the created friend with its new ID
f.ID, _ = result.LastInsertId()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(f)
}
What does this code do?
- json.NewDecoder(r.Body).Decode(&f) reads the request body and maps JSON fields to the Friend struct based on the json: tags you defined earlier.
- strings.TrimSpace catches names that are just whitespace. The handler returns 400 Bad Request if validation fails.
- w.WriteHeader(http.StatusCreated) sends a 201 status code, which is the standard response for successful resource creation.
- Add the POST route below the GET route you just added in main():
mux.HandleFunc("POST /api/friends", handleCreateFriend)
- Save main.go and restart your server (Ctrl+C then go run main.go).
- Create a friend by running this curl command in your other terminal tab:
curl -X POST http://localhost:8080/api/friends -H "Content-Type: application/json" -d '{"name": "Alex"}'
You should see {"id":1,"name":"Alex"} returned with the auto-generated ID.
- Now verify the GET endpoint returns your new friend:
curl http://localhost:8080/api/friends
You should see [{"id":1,"name":"Alex"}] as a JSON array containing your friend.
Getting 'Invalid JSON' or 'Name is required'?
- Make sure your curl command includes -H "Content-Type: application/json" so the server knows you're sending JSON.
- Check that the JSON body uses the key "name" (lowercase), matching the struct's json:"name" tag.
Help me debug my POST request to the friends endpoint.
Implement the items endpoints
Items are more complex than friends because each item belongs to a friend. Your query needs to join the two tables so the response includes the friend's name alongside each item.
- Add the queryItems helper function below handleCreateFriend. This first part handles the SQL query logic:
func queryItems(search string) ([]Item, error) {
var rows *sql.Rows
var err error
// Choose the query based on whether we're searching
if search == "" {
rows, err = db.Query(`
SELECT items.id, items.friend_id, friends.name, items.name, items.available
FROM items
JOIN friends ON items.friend_id = friends.id
ORDER BY items.name
`)
} else {
like := "%" + search + "%"
rows, err = db.Query(`
SELECT items.id, items.friend_id, friends.name, items.name, items.available
FROM items
JOIN friends ON items.friend_id = friends.id
WHERE items.name LIKE ? OR friends.name LIKE ?
ORDER BY items.name
`, like, like)
}
What does this code do?
This function takes a search string and branches: if it's empty, it fetches all items. If it has a value, it filters using SQL LIKE on both the item name and the friend name.
The JOIN connects the items and friends tables so you get the friend's name in the same row as the item. The ? placeholders prevent SQL injection by letting the driver safely insert the values.
- Directly below the closing } of the if/else block you just added, paste this code to complete the queryItems function:
if err != nil {
return nil, err
}
defer rows.Close()
// Scan each row into an Item struct
var items []Item
for rows.Next() {
var item Item
if err := rows.Scan(&item.ID, &item.FriendID, &item.FriendName, &item.Name, &item.Available); err != nil {
return nil, err
}
items = append(items, item)
}
// Return an empty slice instead of nil for clean JSON output
if items == nil {
items = []Item{}
}
return items, nil
}
Why check for nil items?
In Go, an uninitialized slice is nil. When you encode nil to JSON, it becomes null. By replacing it with an empty slice, the JSON response shows [] instead, which is cleaner for API consumers to parse.
- Add the handleGetItems and handleCreateItem handlers below queryItems. First, the GET handler:
func handleGetItems(w http.ResponseWriter, r *http.Request) {
// Fetch all items using the shared helper
items, err := queryItems("")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
What does this code do?
This handler passes an empty string to queryItems, which triggers the "fetch all" branch. The search endpoint you'll build in the next step reuses this same function with a non-empty query.
- Now add handleCreateItem directly below handleGetItems:
func handleCreateItem(w http.ResponseWriter, r *http.Request) {
var item Item
if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
// Validate required fields
if strings.TrimSpace(item.Name) == "" || item.FriendID == 0 {
http.Error(w, "name and friend_id are required", http.StatusBadRequest)
return
}
// Insert with available defaulting to true
result, err := db.Exec("INSERT INTO items (friend_id, name, available) VALUES (?, ?, ?)", item.FriendID, item.Name, true)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
item.ID, _ = result.LastInsertId()
item.Available = true
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(item)
}
What does this code do?
- The validation checks both Name (not blank) and FriendID (not zero). An item must always belong to a friend.
- New items are always created as available (true). The toggle endpoint will flip this later.
- The response sets Available = true on the struct before encoding so the response JSON includes the correct value.
- Register both item routes in main() below your friends routes:
mux.HandleFunc("GET /api/items", handleGetItems)
mux.HandleFunc("POST /api/items", handleCreateItem)
- Save main.go and restart the server.
- Create an item belonging to Alex (friend ID 1) by running:
curl -X POST http://localhost:8080/api/items -H "Content-Type: application/json" -d '{"name": "Vacuum cleaner", "friend_id": 1}'
You should see {"id":1,"friend_id":1,"friend_name":"","name":"Vacuum cleaner","available":true} returned. The friend_name is empty in the creation response because the insert doesn't join with friends.
- Now fetch all items to see the joined data:
curl http://localhost:8080/api/items
This time you should see the full item with "friend_name":"Alex" populated by the JOIN query in queryItems.
Getting a foreign key error?
- If you see a FOREIGN KEY constraint error, make sure the friend_id value in your POST matches an existing friend's ID. Run curl http://localhost:8080/api/friends first to check which IDs exist.
- If you restarted with a fresh database, you need to POST a friend again before creating items.
Help me fix a foreign key error when creating items.
Implement the toggle endpoint
The whole point of tracking availability is knowing whether you can actually borrow something right now. The toggle endpoint flips an item between available and lent out with a single request.
- Add the handleToggleItem function below handleCreateItem:
func handleToggleItem(w http.ResponseWriter, r *http.Request) {
// Extract the item ID from the URL path
id := r.PathValue("id")
// Flip the available boolean using SQL NOT
_, err := db.Exec("UPDATE items SET available = NOT available WHERE id = ?", id)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "toggled"})
}
What does this code do?
- r.PathValue("id") extracts the {id} wildcard from the route pattern. This is the Go 1.22+ way of reading path parameters without a third-party router.
- NOT available in SQL flips a boolean value. If the item is currently available (1), it becomes unavailable (0), and vice versa.
- The response is a simple JSON object confirming the toggle happened. A future GET request will show the updated availability.
- Register the toggle route in main() below your items routes:
mux.HandleFunc("PATCH /api/items/{id}/toggle", handleToggleItem)
- Save main.go and restart the server.
Before you test, think about what you expect. You created a Vacuum cleaner as available. What should happen when you send a PATCH request to toggle it?
- Toggle the vacuum cleaner's availability by running:
curl -X PATCH http://localhost:8080/api/items/1/toggle
You should see {"status":"toggled"} confirming the flip happened.
- Verify the availability actually changed by fetching all items:
curl http://localhost:8080/api/items
The Vacuum cleaner should now show "available":false. Your API can create friends, log items, and toggle availability.
Item still shows available as true?
- Make sure your curl command uses -X PATCH (not POST or GET). The route only matches PATCH requests.
- Check that the URL ends with /1/toggle where 1 is the actual item ID from your POST response.
Help me debug the toggle endpoint.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file, and make sure your server restarts cleanly with no compile errors.
ⓧ I'd like to double check the full code
Scroll up through your work and confirm each of these pieces is present, in order:
- Import block with encoding/json and strings added.
- In main(): five mux.HandleFunc calls for GET /api/friends, POST /api/friends, GET /api/items, POST /api/items, and PATCH /api/items/{id}/toggle.
- handleGetFriends function querying and returning friends as JSON.
- handleCreateFriend function with JSON decode, name validation, INSERT, and 201 response.
- queryItems helper with the if/else query branching and row scanning loop.
- handleGetItems calling queryItems("") and encoding the result.
- handleCreateItem with name and friend_id validation, INSERT, and 201 response.
- handleToggleItem using r.PathValue("id") and SQL NOT available.
Your API handles creating friends, logging items, and toggling availability. Next up, you'll add a search endpoint so you can instantly find which friend has what you need.
Add the Search Endpoint
Your API can create friends, list items, and toggle availability. But the whole point of BorrowBuddy is answering a specific question: "who has a vacuum cleaner?" Right now, you can only dump every item in the database.
In this step, you will add a search endpoint that filters items by name or owner. The queryItems helper you wrote in the last step already accepts a search string. All you need is a thin handler that reads a query parameter and calls it.
In this step, get ready to:
- Write a search handler that reads the q query parameter and returns matching items as JSON.
- Register the GET /api/search route and confirm filtered results with curl.
Write the search handler
The search endpoint reads a q parameter from the URL's query string, passes it to queryItems, and returns the filtered results as JSON. Since queryItems already handles the LIKE query, this handler stays small.
- In main.go, add the following function below your handleToggleItem function:
func handleSearch(w http.ResponseWriter, r *http.Request) {
// Read the "q" parameter from the URL query string
query := r.URL.Query().Get("q")
items, err := queryItems(query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(items)
}
What does this code do?
- r.URL.Query().Get("q") extracts the value of ?q=something from the request URL.
- If the learner visits /api/search?q=vacuum, the variable query holds "vacuum".
- The existing queryItems helper runs a SQL LIKE search across both item names and friend names, so no new database code is needed.
Now register the route so the server knows to direct search requests to this handler.
- In your main() function, find this line:
mux.HandleFunc("PATCH /api/items/{id}/toggle", handleToggleItem)
- Add the search route directly below it, so the block now looks like this:
mux.HandleFunc("PATCH /api/items/{id}/toggle", handleToggleItem)
mux.HandleFunc("GET /api/search", handleSearch)
- Save main.go.
Test the search endpoint
Time to see the search in action. You need to restart the server since Go recompiles on each run, then seed some test data to search against.
- In your terminal where the server is running, press Ctrl+C to stop it.
- Restart the server by running:
go run main.go
You should see BorrowBuddy server starting on :8080 and Database initialized in the output.
- Open a second terminal tab. Add a friend and a few items so you have data to search by running these commands:
curl -s -X POST http://localhost:8080/api/friends -d '{"name":"Alex"}' -H "Content-Type: application/json"
curl -s -X POST http://localhost:8080/api/items -d '{"friend_id":1,"name":"Vacuum cleaner"}' -H "Content-Type: application/json"
curl -s -X POST http://localhost:8080/api/items -d '{"friend_id":1,"name":"Power drill"}' -H "Content-Type: application/json"
Already have test data?
If you already added friends and items in Step 3, you can skip the commands above. Just make sure at least one item has "vacuum" in its name so the search test below returns a result.
Before you run the search, predict: if you search for "vacuum", do you expect to see all three items or just one?
- Search for items matching "vacuum" by running:
curl "http://localhost:8080/api/search?q=vacuum"
You should see only the vacuum item returned as JSON, including the friend's name and availability status.
[{"id":1,"friend_id":1,"friend_name":"Alex","name":"Vacuum cleaner","available":true}]
Why only one result?
The queryItems function wraps the search term with % wildcards and uses SQL LIKE to match against both item names and friend names. Only items containing "vacuum" (case-insensitive in SQLite) match, so "Power drill" is excluded.
- Try searching with an empty query to confirm it returns all items:
curl "http://localhost:8080/api/search?q="
This should return every item in the database, because an empty search string makes queryItems fall through to the unfiltered query.
Getting an empty array or an error?
- If you see [] for the vacuum search, make sure you actually POSTed an item with "vacuum" in its name. Run curl http://localhost:8080/api/items to list everything in the database.
- If you see 404 page not found, check that you registered the route as "GET /api/search" (not "/api/search") and restarted the server after saving.
- If the server did not start at all, check the terminal for a compile error. A missing comma or brace in the route registration block is the most common cause.
Still stuck? Help me debug my /api/search endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go with both the new handleSearch function and the route registration.
ⓧ I'd like to double check the full code
Scroll up through your main.go and confirm these pieces are present in order:
- The route registration mux.HandleFunc("GET /api/search", handleSearch) sits after the toggle route in main().
- The handleSearch function appears after handleToggleItem and before queryItems.
- Inside handleSearch, you read r.URL.Query().Get("q"), call queryItems(query), and encode the result as JSON.
Your API now answers the core question: "who has what I need?" Next up, you will build a web interface so you can search and browse without touching curl.
Build the Web Interface
Your API works perfectly via curl, but raw JSON is not satisfying to demo. Imagine showing a friend your BorrowBuddy app and making them squint at terminal output.
Go's html/template package lets you serve dynamic HTML from the same server that handles your API. In this step, you will build a web page that displays items, their owners, and availability badges with a working search bar.
In this step, get ready to:
- Create an HTML template with CSS styles, a search bar, and item cards.
- Build a handler that queries data and renders the template.
- Wire up the search form to filter displayed results.
Create the HTML template
Go's html/template package parses HTML with special placeholders like {{.Name}} and fills them in with Go data at render time. You will store the entire template as a string constant in main.go.
- In main.go, scroll to the very bottom of the file (after the queryItems function).
- Add the first part of the HTML template by pasting this code at the bottom:
var indexHTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BorrowBuddy</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
h1 { color: #333; margin-bottom: 20px; }
.search-form { margin-bottom: 20px; display: flex; gap: 10px; }
.search-form input[type="text"] { flex: 1; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; }
.search-form button { padding: 10px 20px; background: #4CAF50; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; }
.search-form button:hover { background: #45a049; }
.item-list { list-style: none; }
.item-card { background: white; padding: 15px; margin-bottom: 10px; border-radius: 8px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.item-info { display: flex; flex-direction: column; gap: 4px; }
.item-name { font-weight: 600; font-size: 16px; }
.item-owner { color: #666; font-size: 14px; }
.badge { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
.badge-available { background: #e8f5e9; color: #2e7d32; }
.badge-lent { background: #fff3e0; color: #e65100; }
.empty { text-align: center; padding: 40px; color: #999; }
.add-section { background: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.add-section h2 { margin-bottom: 10px; font-size: 16px; }
.add-form { display: flex; gap: 10px; flex-wrap: wrap; }
.add-form input, .add-form select { padding: 8px; border: 1px solid #ddd; border-radius: 6px; }
.add-form button { padding: 8px 16px; background: #2196F3; color: white; border: none; border-radius: 6px; cursor: pointer; }
</style>
</head>
What does this code do?
This declares a package-level variable indexHTML using a raw string literal (backticks). Raw strings can span multiple lines, which makes them perfect for embedding HTML.
The CSS styles define the layout for item cards, availability badges (green for available, orange for lent out), and a search bar.
- Continue the template by pasting this code immediately below the </head> line you just added:
<body>
<h1>BorrowBuddy</h1>
<form class="search-form" method="GET" action="/">
<input type="text" name="q" placeholder="Search items (e.g., vacuum, drill)..." value="{{.Query}}">
<button type="submit">Search</button>
</form>
<ul class="item-list">
{{if .Items}}
{{range .Items}}
<li class="item-card">
<div class="item-info">
<span class="item-name">{{.Name}}</span>
<span class="item-owner">Owned by {{.FriendName}}</span>
</div>
{{if .Available}}
<span class="badge badge-available">Available</span>
{{else}}
<span class="badge badge-lent">Lent out</span>
{{end}}
</li>
{{end}}
{{else}}
<li class="empty">No items found. Add some friends and their items below!</li>
{{end}}
</ul>
</body>
</html>`
What does this code do?
- The <form> sends a GET request to / with the search text as a q query parameter.
- The {{range .Items}} block loops over each item and renders a card with the item name, owner, and an availability badge.
- The {{if .Available}} conditional picks between a green "Available" badge and an orange "Lent out" badge.
- The closing backtick ends the raw string that started with var indexHTML = ` above.
- Save main.go.
Seeing a syntax error in your editor?
Make sure the closing backtick (`) sits on its own at the very end of the </html> line with no trailing spaces. The raw string opened with a backtick after var indexHTML = must close with a matching one.
Your editor may also flag an "unused import" warning for html/template or show errors because the handler function does not exist yet. That is expected and will resolve in the next substep.
Wire up the page handler
The template needs Go code to feed it data. You will create a struct to hold page data, a helper to fetch all friends, and the main handler that ties everything together.
- In main.go, add the PageData struct and getAllFriends function just above the var indexHTML line:
func getAllFriends() ([]Friend, error) {
rows, err := db.Query("SELECT id, name FROM friends ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()
var friends []Friend
for rows.Next() {
var f Friend
if err := rows.Scan(&f.ID, &f.Name); err != nil {
return nil, err
}
friends = append(friends, f)
}
if friends == nil {
friends = []Friend{}
}
return friends, nil
}
type PageData struct {
Items []Item
Friends []Friend
Query string
}
What does this code do?
- The getAllFriends function queries the friends table and returns the full list. The handler will use this to populate the template.
- The PageData struct bundles everything the template needs: items, friends, and the current search query. Go templates access struct fields using dot notation like {{.Query}}.
- Now add the handleIndex function just above getAllFriends:
func handleIndex(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
items, err := queryItems(query)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
friends, err := getAllFriends()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
data := PageData{
Items: items,
Friends: friends,
Query: query,
}
tmpl := template.Must(template.New("index").Parse(indexHTML))
tmpl.Execute(w, data)
}
What does this code do?
- The handler reads the q query parameter from the URL. If the user searched for "vacuum", this value is "vacuum". If no search, it is an empty string.
- It reuses your existing queryItems function, which already handles both filtered and unfiltered queries.
- The template.Must call parses the HTML string and panics if there is a syntax error. Then Execute fills in the template placeholders with the PageData values.
Two more small changes connect everything together.
- Scroll up to the import block at the top of main.go.
- Find this line:
"fmt"
- Add "html/template" on a new line directly below it, so the imports look like this:
"fmt"
"html/template"
- Now scroll down to the main function.
- Find the existing root route handler:
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "BorrowBuddy is running")
})
- Replace those three lines with this single line:
// Web interface
mux.HandleFunc("GET /{$}", handleIndex)
- Also add a comment // API routes on the line above mux.HandleFunc("GET /api/friends", handleGetFriends) to keep things organized.
- Save main.go.
Before you run this, what do you expect to see when you visit http://localhost:8080 in your browser? Think about what data is already in your database from the previous step.
- Stop the running server by pressing Ctrl+C in the terminal.
- Restart the server by running:
go run main.go
- Open http://localhost:8080 in your browser.
You should see a styled page with a green search bar at the top, and item cards showing the friends and items you added via curl in the previous step. Each item displays its owner name and a green "Available" badge.
Seeing a compile error?
- Check that "html/template" is in your import block. Without it, template.Must is undefined.
- Make sure the backtick at the end of the HTML template is on the same line as </html> (or immediately after it). A missing closing backtick causes a "string not terminated" error.
- Verify you removed the old anonymous function from the GET /{$} route. Having two handlers for the same path causes a panic.
Help me fix a compile error in my BorrowBuddy handleIndex setup
Test the search bar
The search form submits to the same page with a q query parameter. Your handleIndex reads that parameter and passes it to queryItems, which filters with a SQL LIKE query.
- In your browser, type the name of one of the items you added earlier (for example, vacuum) into the search bar.
- Click the Search button.
The page reloads showing only the items that match your search. The URL changes to http://localhost:8080/?q=vacuum. Clear the search bar and click Search again to see all items return.
Why reuse queryItems instead of writing new search logic?
The queryItems function you built for the API already handles both the "show all" and "filter by search" cases. By calling it from handleIndex with the same q parameter, the web interface and the API stay perfectly in sync with zero code duplication.
✔️ Awesome, I've got everything!
Great. Double check you have saved main.go and your server is still running.
ⓧ I'd like to double check the full code
Scroll through your main.go and confirm these sections are present in order:
- The import block includes "html/template" between "fmt" and "log".
- The main function uses mux.HandleFunc("GET /{$}", handleIndex) instead of the old anonymous handler.
- The handleIndex function is present after your API handlers.
- The getAllFriends function and PageData struct follow.
- The var indexHTML string starts with <!DOCTYPE html> and ends with </html>` (backtick closes the raw string).
You have a fully working Go web application. It serves a JSON API and a styled HTML interface from the same server, with search that filters results in real time. Next up is the Secret Mission, where you will add categories and a borrowing flow with timestamps.
Secret mission
Categories and Borrowing Flow
Your tracker works, but real inventories need organization. What if you could tag items by category and track exactly who borrowed what and when? This extension adds item categories with colored tags and a full borrow/return flow with timestamps.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Project folder on your Desktop (contains main.go, go.mod, go.sum).
- borrowbuddy.db SQLite database file inside the project folder.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your friend inventory data intact.
- Your borrowbuddy.db file persists all your friend and item data between runs.
- To start the server again anytime, open your terminal, navigate to the project folder, and run go run main.go.
Pause - I'll come back to this later
Shut down the running server to free up the terminal. Your project files and database stay on disk for next time.
- Switch to the terminal where the server is running.
- Press Ctrl+C to stop the server.
- Your files and database remain in the project folder on your Desktop. Run go run main.go from that folder whenever you want to pick up again.
Delete - I don't want to use this again
Remove all project files, the database, and start fresh. This deletes your friend and item data permanently.
- If the server is still running, press Ctrl+C to stop it.
- Navigate to your Desktop and delete the entire project folder by running:
cd ~/Desktop
rm -rf borrowbuddy
- On Windows, use this command instead:
cd ~\Desktop
Remove-Item -Recurse -Force borrowbuddy
- This removes main.go, go.mod, go.sum, and the borrowbuddy.db database file.
Nice Work!
Nice Work!
Nice work. You just built a complete web application in Go from scratch, with a REST API, a SQLite database, and a dynamic HTML interface all served from a single binary.
You learned how to:
- Built a REST API with CRUD operations and search using Go's standard library net/http package with Go 1.22+ method-based routing and path parameters.
- Connected to SQLite using a pure-Go driver with proper connection management, foreign keys, and WAL mode for a persistent data layer.
- Served dynamic HTML templates alongside a JSON API from the same server using Go's html/template package.
- Secret Mission: Added item categories with colored tags and a full borrowing/return flow with timestamps.
Ready to quiz yourself?