How to Build a URL Shortener With Go

April 04, 2024
Written by
Reviewed by

How to Build a URL Shortener With Go

URL shorteners, such as Bit.ly, are one of the quintessential web apps. They take a long, likely very hard to remember, URL, such as https://github.com/slicer69/sysvinit/releases/tag/3.09, and shorten it, such as https://tinyurl.com/yhxudwwv.

What's more, we've all likely used them whether we realise it or not, such as indirectly through services such as LinkedIn.

A screenshot of a LinkedIn post showing a shortened URL using LinkedIn's URL shortening service.

While their capabilities vary, however, at their core they do just two things:

  1. Take a long URL and make it shorter, making it easier to read and share

  2. Track the number of times that URL was clicked on

In this tutorial, you're going to learn how to build one with Go.

How will the application work?

When finished, the app will have three routes:

  • The first will retrieve a list of all of the shortened URLs stored in the application's database and display them, along with a form for shortening URLs.

  • The second route will process form requests to shorten URLs and store the shortened URL, along with the unshortened URL in the backend, SQLite database. 

  • The third route will retrieve the original URL from the shortened URL and to redirect the user to it.

Prerequisites

To follow along with the tutorial, you will need the following:

  • A recent version of Go (1.22.0 at the time of writing)

  • The command line shell for SQLite (version 3)

  • Your favourite editor or IDE, such as Visual Studio Code or SublimeText

  • Some prior knowledge of Go would be helpful, though not necessary

We could have used a larger, more feature-rich database, such as PostgreSQL or MS SQLServer. But – especially when starting out – SQLite is a great choice! It's quick to set up, self-contained, cross-platform, and uses a tiny amount of memory.

Let's get building!

Create the project directory structure

The first thing to do is to create the project's directory structure, by running the commands below.

mkdir -p go-url-shortener
cd go-url-shortener
mkdir -p templates static/css internals/models data

If you're using Microsoft Windows, you don't need the -p argument.

The created directory structure will look like this:

.
├── data
├── internals
│   └── models
├── static
│   └── css
└── templates

Here's what the directories are for:

  • templates: This will hold the Go HTML template that will be rendered when the default route is viewed

  • static/css: This will store the application's sole CSS file

  • internals/models: This will store the Go source files that provide database interaction support

  • data: This will store the SQLite database file

Install the required dependencies

Next, let's add Go module support and install the required packages, by running the commands below.

go mod init go-url-shortener
go get github.com/gorilla/sessions github.com/julienschmidt/httprouter github.com/justinas/alice modernc.org/sqlite golang.org/x/text github.com/davidmytton/url-verifier

Here's what the packages are for:

Package

Description

This package simplifies validating that URLs are valid before they're shortened.

This package provides Flash message support, which we'll use to show errors when something goes wrong with shortening a URL.

This package is a fast and lightweight router. We're using it as Go's default router doesn't support restricting routes by HTTP method.

This package adds some extra text manipulation support.

This package provides underlying support for interacting with the app's SQLite database.

Provision the database

The next thing to do is provision the database. To keep the process as uncomplicated as possible, we're not going to use any dedicated provisioning tools. Rather, we'll run the DDL commands directly using SQLite's command line shell. 

Before we can do that, in the data directory, create a new file named load.sql. Then, in that file paste the following.

CREATE TABLE IF NOT EXISTS "urls" (
	original_url TEXT PRIMARY KEY NOT NULL,
	shortened_url TEXT NOT NULL,
	clicks INTEGER DEFAULT 0,
	created DATETIME DEFAULT CURRENT_TIMESTAMP,
	updated DATETIME DEFAULT CURRENT_TIMESTAMP,
	CONSTRAINT uniq_original_url UNIQUE (original_url)
);

CREATE index idx_shortened ON urls (shortened_url);

INSERT INTO urls (original_url, shortened_url, clicks) 
VALUES
("https://osnews.com", "shoRtkl9187ds", 347),
("https://stackoverflow.com/questions/tagged/go", "sh0Rtkl9187es", 2809);

The first instruction creates a table named urls. This table stores the shortened and unshortened URLs (original_url, shortened_url), along with the number of times that a shortened URL was clicked (clicks).

The second adds an index on the shortened URL column (idx_shortened). This improves database performance, as that column is queried the most. The third inserts two records into the table, so that there's something to look at when we test the application, later.

Run the command below to provision the database.

sqlite3 data/database.sqlite3 < data/load.sql

Did you know that Kevin Gilbertson created the first URL shortener, named TinyURL, in 2002?

Build the URL shortener

Now, it's time to write some code. Start off by creating a new file in the internals/models directory named urls.go. In the file, past the code below.

package models

import (
    "database/sql"
)

type ShortenerData struct {
    OriginalURL, ShortenedURL string
    Clicks                	int
}

type ShortenerDataModel struct {
    DB *sql.DB
}

func (m *ShortenerDataModel) Latest() ([]*ShortenerData, error) {
    stmt := `SELECT original_url, shortened_url, clicks FROM urls`
    rows, err := m.DB.Query(stmt)
    if err != nil {
   	 return nil, err
    }
    defer rows.Close()

    urls := []*ShortenerData{}
    for rows.Next() {
   	 url := &ShortenerData{}
   	 err := rows.Scan(&url.OriginalURL, &url.ShortenedURL, &url.Clicks)
   	 if err != nil {
   		 return nil, err
   	 }
   	 urls = append(urls, url)
    }

    if err = rows.Err(); err != nil {
   	 return nil, err
    }
    return urls, nil
}

The code defines two structs:

  • ShortenerData: This stores the records from one table row; the original and shortened URLs and the number of times a shortened URL was clicked.

  • ShortenerDataModel: This manages the interaction with the SQLite database.

Then, a function, Latest() is defined on ShortenerDataModel. This function retrieves all of the records from the url table, then hydrates and returns an array of ShortenerData with the retrieved data.

Add the ability to retrieve shortened URLs from the database

Now, let's create the app's default route that both retrieves the shortened URLs from the database and displays them along with a form to shorten URLs.

To do that, in the project's top-level directory create a new file, named main.go. In that file, paste the code below.

package main

import (
    "database/sql"
    "flag"
    "fmt"
    "go-url-shortener/internals/models"
    "html/template"
    "log"
    "net/http"
    "os"

    "github.com/julienschmidt/httprouter"
    "github.com/justinas/alice"
    "golang.org/x/text/language"
    "golang.org/x/text/message"
    "golang.org/x/text/number"

    _ "modernc.org/sqlite"
)

type PageData struct {
    BaseURL, Error string
    URLData    	[]*models.ShortenerData
}

type App struct {
    urls *models.ShortenerDataModel
}

func serverError(w http.ResponseWriter, err error) {
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
}

func newApp(dbFile string) App {
    db, err := sql.Open("sqlite", dbFile)
    if err != nil {
        log.Fatal(err)
    }

    if err = db.Ping(); err != nil {
        log.Fatal(err)
    }

    return App{urls: &models.ShortenerDataModel{DB: db}}
}

var functions = template.FuncMap{
    "formatClicks": formatClicks,
}

func formatClicks(clicks int) string {
    p := message.NewPrinter(language.English)
    return p.Sprintf("%v", number.Decimal(clicks))
}

func (a *App) getDefaultRoute(w http.ResponseWriter, r *http.Request) {
    tmplFile := "./templates/default.html"
    tmpl, err := template.New("default.html").Funcs(functions).ParseFiles(tmplFile)
    if err != nil {
        fmt.Println(err.Error())
        serverError(w, err)
        return
    }

    urls, err := a.urls.Latest()
    if err != nil {
        fmt.Printf("Could not retrieve all URLs, because %s.\n", err)
        return
    }

    baseURL := "http://" + r.Host + "/"
    pageData := PageData{
   	 URLData: urls,
   	 BaseURL: baseURL,
    }

    err = tmpl.Execute(w, pageData)
    if err != nil {
        fmt.Println(err.Error())
        serverError(w, err)
    }
}

func (a *App) routes() http.Handler {
    router := httprouter.New()
    fileServer := http.FileServer(http.Dir("./static/"))
    router.Handler(http.MethodGet, "/static/*filepath", http.StripPrefix("/static", fileServer))

    router.HandlerFunc(http.MethodGet, "/", a.getDefaultRoute)

    standard := alice.New()

    return standard.Then(router)
}

func main() {
    app := newApp("data/database.sqlite3")
    addr := flag.String("addr", ":8080", "HTTP network address")

    infoLog := log.New(os.Stdout, "INFO\t", log.Ldate|log.Ltime)
    errorLog := log.New(os.Stderr, "ERROR\t", log.Ldate|log.Ltime|log.Lshortfile)

    defer app.urls.DB.Close()

    srv := &http.Server{
        Addr: 	*addr,
        ErrorLog: errorLog,
        Handler:  app.routes(),
    }

    infoLog.Printf("Starting server on %s", *addr)
    err := srv.ListenAndServe()
    errorLog.Fatal(err)
}

The code starts off by defining two structs: 

  • PageData: this holds data to be passed to the default route's template, such as any errors (Error), the app's base URL (in our case, http://localhost:8080), and the URL data (URLData). 

  • App: this models the application. It has one property, urls, which is a pointer to a models.ShortenerDataModel object that, as we've started to see, simplifies database interaction.

Then, it defines the serverError() function. This is a small utility function for returning an HTTP 500 error, when something serious goes wrong in the application.

Following that, the newApp() function instantiates a new App object. The function takes the path to the SQLite database file, opens a connection to it (db), checks that it works, and returns an initialised App object.

Then, it defines the formatClicks() function. This function takes the number of clicks a shortened URL has received and formats it as a string with a thousands separator. It's not strictly necessary, but I thought it makes the table output on the default route that much easier to read.

If your main language isn't English, feel free to change language.English in the call to message.NewPrinter(), accordingly.

After that, the function is then added to the list of additional Go template functions. It can be referred to in a Go template with formatClicks, such as in the following example.

<td>{{ .Clicks | formatClicks }}</td>

The next method, getDefaultRoute(), is, likely, the most important as it handles requests to the app's default route (/). The function opens and parses the route's template, templates/default.html, which will be defined next. During this process, the additional list of Go template functions we defined earlier is added by calling Template's Funcs() function

It then retrieves all of the URL data from the database and stores them in PageData. Then, the function finishes up by writing the rendered template, along with the URL data, in the response to the request.

Then, another function, named routes(), is defined on App. This function defines the app's routing table. The function starts by creating a route to handle requests to the application's static files, stored in the static directory. Then, it defines the default route, setting App's getDefaultRoute() to handle requests to it.

Check out httprouter's documentation, if you're not familiar with the package.

Finally, the main() function is defined. The function instantiates a new App object, passing it the path to the SQLite database in the data directory. It then instantiates a new HTTP server listening on localhost on port 8080, with the routes defined in App's routes() function.

Define the default route's template

We're almost ready to do an initial test of the application. Before we can do that we need to define the Go template which will be rendered and displayed on requests to the default route.

Create a new file in the templates directory named default.html. In that file, paste the code below.

<!doctype html>
<html lang="en">

<head>
	<meta charset="UTF-8">
	<meta name="viewport" content="width=device-width, initial-scale=1.0">
	<link href="/static/css/styles.css" rel="stylesheet">
	<title>A Go URL Shortener</title>
</head>

<body class="bg-gradient-to-b from-bg-slate-400 to-bg-white text-slate-800 antialiased dark:bg-slate-900">

	<main class="mb-4">

		<div class="bg-slate-800 pb-6 drop-shadow-md shadow-md">

			<header class="mx-auto my-auto lg:max-w-8xl lg:w-[70rem] w-full px-4 pt-6 mb-1">
				<h1 class="text-4xl font-bold text-left mb-4 text-white">A Go URL Shortener</h1>
			</header>

			<div class="mx-auto my-auto lg:max-w-8xl lg:w-[70rem] w-full px-4 mt-6 mb-1">

				<form id="link-shortener"
					class="flex flex-col rounded-md border-2 border-slate-800 dark:border-slate-600 p-6 dark:shadow-md shadow-sm rounded-lg bg-slate-700"
					action="/" method="post">
					<div class="grow mb-1">
						<label>
							<input placeholder="Enter a URL to shorten" type="text" name="url" required
								class="w-full border-2 rounded-md py-2 dark:placeholder:text-slate-400 px-3 bg-slate-100 transition ease-in-out delay-150 duration-200 hover:bg-slate-200">
						</label>
						{{/* Only display the error field, if there is an error */}}
						{{ if ne .Error "" }}
						<div id="url-error"
							class="mt-3 rounded-md bg-red-800 border-4 border-red-900 text-white pl-4 py-3 font-medium">
							Oops! {{ .Error }}
						</div>
						{{ end }}
					</div>
					<input type="submit" name="submit" value="Shorten URL"
						class="hover:cursor-pointer flex-none font-medium border-0 border-slate-600 shadow-md hover:shadow-none bg-slate-600 w-full mt-3 text-white px-3 py-4 uppercase rounded-md transition ease-in-out delay-150 duration-200 hover:bg-slate-600 caret-slate-700 focus:ring-4 focus:ring-offset-4 focus:ring-inset">
				</form>

			</div>

		</div>

		<hr class="w-48 h-1 mx-auto my-4 bg-slate-200 dark:bg-slate-800 border-0 shadow-sm rounded md:my-5 md:mb-5">

		<div class="mx-auto my-auto lg:max-w-8xl lg:w-[70rem] w-full px-4 mt-3 mb-4">
			<table id="shortened-links-table"
				class="w-full table-fixed rounded-md bg-slate-50 dark:bg-slate-800 border-separate border-spacing-2 border-2 dark:border-0 border-slate-200 shadow-sm">
				<thead>
					<tr>
						<th
							class="border border-slate-300 rounded-sm pl-4 text-left bg-slate-200 dark:text-white dark:bg-slate-800 dark:border-0 py-2">
							Shortened URL</th>
						<th
							class="border border-slate-300 rounded-sm pl-4 text-left bg-slate-200 dark:text-white dark:bg-slate-800 dark:border-0">
							Original URL</th>
						<th
							class="border border-slate-300 rounded-sm bg-slate-200 dark:text-white dark:bg-slate-800 dark:border-0">
							Clicks</th>
					</tr>
				</thead>
				<tbody class="text-center">
					{{ if len .URLData | eq 0 }}
					<tr>
						<td colspan="3"
							class="border border-slate-300 py-2 pl-4 rounded-sm bg-white dark:text-white dark:bg-slate-700 dark:border-0">
							No URLs have been shortened, yet.
							Want to shorten one?
						</td>
					</tr>
					{{ end }}
					{{/* Iterate over the existing URL data */}}
					{{ range .URLData }}
					<tr>
						<td
							class="border border-slate-300 py-2 pl-4 text-left rounded-sm bg-white dark:text-white dark:bg-slate-700 dark:border-0">
							<a href="{{$.BaseURL }}o/{{ .ShortenedURL }}" target="_blank"
								class="hover:underline underline-offset-4 decoration-2 decoration-blue-500 dark:decoration-slate-500">{{$.BaseURL
								}}o/{{ .ShortenedURL }}</a>
						</td>
						<td
							class="border border-slate-300 py-2 px-4 text-left block rounded-sm bg-white dark:bg-slate-700 dark:border-0">
							<div class="truncate" title="{{ .OriginalURL }}">{{ .OriginalURL }}</div>
						</td>
						<td
							class="border border-slate-300 py-2 rounded-sm bg-white dark:text-white dark:bg-slate-700 dark:border-0">
							{{ .Clicks | formatClicks }}</td>
					</tr>
					{{ end }}
				</tbody>
				<tfoot>
					<tr>
						<td colspan="3" class="pl-1 text-sm text-slate-500 text-right">{{ .URLData | len }} shortened
							URLs available.</td>
					</tr>
				</tfoot>
			</table>
		</div>
	</main>

	<footer
		class="mx-auto my-auto lg:max-w-8xl lg:w-[70rem] w-full px-4 mt-2 mb-0 pl-5 lowercase text-slate-400 dark:text-slate-500 text-sm text-center mb-4">
		<a href="#"
			class="hover:underline underline-offset-4 decoration-2 decoration-slate-300 transition ease-in-out delay-150 duration-100">
			Created by Matthew Setter.
		</a>
		<a href="#"
			class="hover:underline underline-offset-4 decoration-2 decoration-slate-300 transition ease-in-out delay-150 duration-100">
			Powered by Twilio.
		</a>
	</footer>

</body>

</html>

The template is split into two parts. It has a form for shortening URLs, at the top of the page, and a table listing all of the database records at the bottom. It's styled a little, so that it has, I hope, a professional look and feel.

The form contains a single input field named url, which takes advantage of the URL input type to simplify handling invalid values. As a result, if the user enters a value that isn't identifiable as a URL, your browser will display an error message, avoiding us having to implement one in code. 

Otherwise, the form will send the form data to the app's second route (which we'll define shortly), where it will be processed. Clicking on the shortened URLs, opens the app's third route, which redirects the user to the original URL.

I appreciate that the template's quite verbose, as I've used quite a number of Tailwind CSS classes to define the UI. I'm an unabashed fan of the framework as it's made life so much easier building web apps.

Download the CSS file

Next, download the application's CSS file to the static/css directory; saving you the hassle of generating it yourself.

Let's see the application in action!

With the core of the application in place, let's have a look at it. Start it by running the command below.

go run main.go

Then, in your browser of choice, open http://localhost:8080. It should look similar to the screenshot below.

The default page of the URL shortener. The form to shorten URLs is at the top of page and a table of two shortened URLs is underneath

Add the ability to shorten a URL

We can view all of the shortened URLs in the database, so let's add the ability to shorten a URL and store it in the database. To do that, we're going to:

  1. Update the database code

  2. Add a route to process submissions from the URL shortener form in the default template

The first thing we'll do is to add the following function after the existing one in internals/models/urls.go.

func (m *ShortenerDataModel) Insert(original string, shortened string, clicks int) (int, error) {
    stmt := `INSERT INTO urls  (original_url, shortened_url, clicks) VALUES(?, ?, ?)`
    result, err := m.DB.Exec(stmt, original, shortened, clicks)
    if err != nil {
   	 return 0, err
    }

    rowsAffected, err := result.RowsAffected()
    if err != nil {
   	 return 0, err
    }

    return int(rowsAffected), nil
}

This function inserts a new record into the urls table and returns the number of rows affected, if any. Alternatively, it returns an error if something went wrong while inserting the record.

Next, in main.go add the following code after the imports list at the top of the file.

func uniqid(prefix string) string {
    now := time.Now()
    sec := now.Unix()
    usec := now.UnixNano() % 0x100000

    return fmt.Sprintf("%s%08x%05x", prefix, sec, usec)
}

func (a *App) GenerateShortenedURL() string {
    var (
   	 randomChars   = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0987654321")
   	 randIntLength = 27
   	 stringLength  = 32
    )

    str := make([]rune, stringLength)

    for char := range str {
   	 nBig, err := rand.Int(rand.Reader, big.NewInt(int64(randIntLength)))
   	 if err != nil {
   		 panic(err)
   	 }

   	 str[char] = randomChars[nBig.Int64()]
    }

    hash := sha256.Sum256([]byte(uniqid(string(str))))
    encodedString := base64.StdEncoding.EncodeToString(hash[:])

    return encodedString[0:9]
}

func setErrorInFlash(error string, w http.ResponseWriter, r *http.Request) {
    session, err := store.Get(r, "flash-session")
    if err != nil {
   	 fmt.Println(err.Error())
    }
    session.AddFlash(error, "error")
    session.Save(r, w)
}

var store = sessions.NewCookieStore([]byte("My super secret authentication key"))

Then, update the imports list to match the following.

import (
    "crypto/rand"
    "crypto/sha256"
    "database/sql"
    "encoding/base64"
    "flag"
    "fmt"
    "go-url-shortener/internals/models"
    "html/template"
    "log"
    "math/big"
    "net/http"
    "os"
    "time"

    urlverifier "github.com/davidmytton/url-verifier"
    "github.com/gorilla/sessions"
    "github.com/julienschmidt/httprouter"
    "github.com/justinas/alice"
    "golang.org/x/text/language"
    "golang.org/x/text/message"
    "golang.org/x/text/number"

    _ "modernc.org/sqlite"
)

After that, in a new terminal session or tab, run the following command, to ensure that the new packages are available.

go mod tidy

The first two functions generate a shortened URL, minus the URL scheme. They generate a string of up to 27 random characters, factoring in the current time, and then encode that string.

For complete transparency, I found the functions at https://www.php2golang.com/method/function.uniqid.html.

The third function, setErrorInFlash(), stores a flash message in the current session. If you're not familiar with flash messages, they're one-time messages that are passed between requests. In our case, we'll store an error message before the user is redirected back to the default route. There, the error message will be displayed in the form, between the input field and submit button.

Next, add the following function in main.go after getDefaultRoute().

func (a *App) shortenURL(w http.ResponseWriter, r *http.Request) {
    err := r.ParseForm()
    if err != nil {
   	 fmt.Println(err.Error())
   	 serverError(w, err)
   	 return
    }

    originalURL := r.PostForm.Get("url")
	if originalURL == "" {
   	 setErrorInFlash("Please provide a URL to shorten.", w, r)
   	 http.Redirect(w, r, "/", http.StatusSeeOther)
   	 return
    }

    verifier := urlverifier.NewVerifier()
    verifier.EnableHTTPCheck()
    result, err := verifier.Verify(originalURL)

    if err != nil {
   	 fmt.Println(err.Error())
   	 setErrorInFlash(err.Error(), w, r)
   	 http.Redirect(w, r, "/", http.StatusSeeOther)
   	 return
    }

    if !result.IsURL {
   	 fmt.Printf("[%s] is not a valid URL.\n", originalURL)
   	 setErrorInFlash("Sorry. I can only shorten valid URLs", w, r)
   	 http.Redirect(w, r, "/", http.StatusSeeOther)
   	 return
    }

    if !result.HTTP.Reachable {
   	 fmt.Printf("The URL [%s] was not reachable.\n", originalURL)
   	 setErrorInFlash("The URL was not reachable.", w, r)
   	 http.Redirect(w, r, "/", http.StatusSeeOther)
   	 return
    }

    shortenedURL := a.GenerateShortenedURL()
    _, err = a.urls.Insert(originalURL, shortenedURL, 0)
    if err != nil {
   	 fmt.Println(err.Error())
   	 setErrorInFlash("We weren't able to shorten the URL.", w, r)
   	 http.Redirect(w, r, "/", http.StatusSeeOther)
   	 return
    }

    fmt.Printf("Redirecting to the default route, after shortening %s to %s and persisting it.\n", originalURL, shortenedURL)

    http.Redirect(w, r, "/", http.StatusSeeOther)
}

The function attempts to retrieve the url parameter from the POST request's body. If the URL was not supplied, or the value supplied was not a valid URL, an applicable error message is flashed to the current session. Then, the user is redirected to the default route, where the message will be displayed. 

Otherwise, a shortened URL is generated, then saved to the database along with the original URL. If a new record cannot be created, a message saying that is flashed. Then, the user is redirected to the default route. Otherwise, the user is redirected to the default route, where they'll see the new URL at the top of the URLs table.

Following that, in main.go, in the getDefaultRoute() function, add the following code in getDefaultRoute() after the initialisation of pageData.

session, err := store.Get(r, "flash-session")
if err != nil {
	http.Error(w, err.Error(), http.StatusInternalServerError)
	return
}

fm := session.Flashes("error")
if fm != nil {
	if error, ok := fm[0].(string); ok {
		pageData.Error = error
	} else {
		fmt.Printf("Session flash did not contain an error message. Contained %s.\n", fm[0])
	}
}
session.Save(r, w)

These changes retrieve an error message and add it to the template data, if one was flashed while processing the form data.

Finally, in main.go, in the routes() function, add the code below after the definition of the default route.

router.HandlerFunc(http.MethodPost, "/", a.shortenURL)

This adds a new route to the routing table, the same as the default, but which, when POST requests are made, will be handled by App's shortenURL() function.

Add the ability to redirect a shortened URL to the original URL

Now, let's add the third and final aspect of functionality, the ability to redirect a user from a shortened URL to the original URL. To do that, in internals/models/urls.go, add the following two functions at the end of the file.

func (m *ShortenerDataModel) Get(shortened string) (string, error) {
    stmt := `SELECT original_url FROM urls WHERE shortened_url = ?`
    var originalURL string
    row := m.DB.QueryRow(stmt, shortened)
    err := row.Scan(&originalURL)
    if err != nil {
   	 if errors.Is(err, sql.ErrNoRows) {
   		 var ErrNoRecord = errors.New("models: no matching record found")
   		 return "", ErrNoRecord
   	 } else {
   		 return "", err
   	 }
    }

    return originalURL, nil
}

func (m *ShortenerDataModel) IncrementClicks(shortened string) error {
    stmt := `UPDATE urls SET clicks = clicks + 1 WHERE shortened_url = ?`
    _, err := m.DB.Exec(stmt, shortened)
    if err != nil {
   	 return err
    }

    return nil
}

The first function, Get(), retrieves and returns the original URL based on the shortened URL supplied. If, however, a record cannot be found matching the shortened URL, a customised Errors object, ErrNoRecord, which we'll see shortly, is returned instead.

The second function, IncrementClicks(), updates the urls table. It increments the value in the clicks column for the record containing the shortened URL.

After that, update the imports list to match the following.

import (
    "database/sql"
    "errors"
)

Next, in main.go paste the code below after shortenURL().

func (a *App) openShortenedRoute(w http.ResponseWriter, r *http.Request) {
    params := httprouter.ParamsFromContext(r.Context())
    shortenedURL := params.ByName("url")

    originalUrl, err := a.urls.Get(shortenedURL)
    if err != nil {
   	 fmt.Println(err.Error())
   	 serverError(w, err)
   	 return
    }

    err = a.urls.IncrementClicks(shortenedURL)
    if err != nil {
   	 fmt.Println(err.Error())
   	 serverError(w, err)
   	 return
    }

    http.Redirect(w, r, originalUrl, http.StatusSeeOther)
}

This retrieves the shortened URL from the request and uses it to retrieve the original URL from the database. If something goes wrong, an HTTP 500 error is returned. Otherwise, the code attempts to increment the clicks for the shortened URL. As before, if something goes wrong, an HTTP 500 error is returned. Assuming that the original URL is returned, the user is redirected to it.

Finally, in main.go, in the routes() function, add the code below after the definition of the second route.

router.HandlerFunc(http.MethodGet, "/o/:url", a.openShortenedRoute)

This defines a new route accessible only with GET requests, that will be handled by the openShortenedRoute() function. The route's path contains a named parameter, :url, containing the shortened URL.

Test that the URL shortener works as expected

That's it. The application now has all of its functionality. So, let's test it. Stop the existing Go process, and restart it by running the command below.

go run main.go

Then, open http://localhost:8080 in your browser of choice. After that, shorten a valid URL, such as https://www.thenewdaily.com.au/. You should see the URL added to the bottom of the list with its clicks set to zero. Clicking on a URL in the Shortened URL column will redirect you to the original URL.

The default page of the URL shortener. The form to shorten URLs is at the top of page and a table of two shortened URLs is underneath

Now, click Shorten URL without entering a URL. You should see an error message appear below the URL text field, as in the screenshot below.

Now, if you enter an invalid URL and click Shorten URL, you'll see the following error appear.

The default page of the URL shortener. The form to shorten URLs is at the top of page, with an error message displayed. A table of two shortened URLs is underneath

And that's how to build a simple URL shortener in Go

While it's not the most feature-rich implementation of a URL shortener, it shows the essential functionality. What would you add or change? 

Would you add record pagination? Would you limit the number of records returned? Would you cache the query results?

Granted, it's not that common to build web apps completely in Go. It's far more common to implement the frontend with a framework, such as Svelte or React. But, for the purposes of a simplistic example, there's no harm in simplifying the technology stack. Have a look at the GitHub repository if you'd like to see the entire code.

Matthew Setter is a PHP Editor in the Twilio Voices team and a PHP, Go, and Rust developer. He’s also the author of Deploy With Docker Compose. When he's not writing PHP code, he's editing great PHP, Go, and Rust tutorials here at Twilio. You can find him at msetter[at]twilio.com, and on LinkedIn, Twitter, and GitHub.