Build a Bookmark API in Go on Top of SQLite and Tests
Build a bookmark API in Go on pure-Go SQLite with tags, URL normalisation, FTS5 search, one in-memory database per test and a suite that runs under -race.

SQLite is the database people forget to consider. It is a library, not a server, so there is nothing to deploy, secure or keep alive beyond a file. For a bookmark manager, an internal tool, a small SaaS with one process, or the backend of a mobile sync endpoint, it is often the right size: fast reads, transactional writes, full text search built in, and a backup that is cp while the app is stopped, or VACUUM INTO while it runs.
Tested with Go 1.25.6, modernc.org/sqlite v1.50.0, macOS on Apple Silicon (arm64)
This project builds a bookmark API in Go on SQLite, with the features that make it more than a toy: URL normalisation so duplicates are detected, many-to-many tags, full text search with FTS5, and a test suite where every test gets a fresh database. It uses the pure Go driver modernc.org/sqlite, so there is no cgo and cross-compilation just works. It builds on the todo API in structure, and the in-memory test approach is covered further in testing against SQLite in memory. Everything ran on Go 1.25.6.
What it does
| Method and path | Purpose |
|---|---|
POST /bookmarks | Save a URL with a title, notes and tags |
GET /bookmarks?tag=&q=&limit= | List, filtered by tag and/or full text query |
GET /bookmarks/{id} | One bookmark |
DELETE /bookmarks/{id} | Remove it, and from search |
The schema
The schema is the first thing to get right, because SQLite's flexibility (it will accept almost anything into any column) puts the responsibility on you.
bookmarkshas aUNIQUEURL and non-null title and notes.tagsholds each tag name once, andbookmark_tagsjoins them, withON DELETE CASCADEso deleting a bookmark removes its links.bookmarks_ftsis an FTS5 virtual table over title and notes. It is declared as an external content table (content='bookmarks'), which means FTS5 stores only its index and reads the text frombookmarkswhen needed. That halves the storage, at the price of keeping the index in sync yourself, which the triggers do.
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"sync/atomic"
_ "modernc.org/sqlite"
)
const schema = `
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY,
url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS tags (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE);
CREATE TABLE IF NOT EXISTS bookmark_tags (
bookmark_id INTEGER NOT NULL REFERENCES bookmarks(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id),
PRIMARY KEY (bookmark_id, tag_id)
);
-- Full text search over title and notes, kept in step by triggers.
CREATE VIRTUAL TABLE IF NOT EXISTS bookmarks_fts USING fts5(title, notes, content='bookmarks', content_rowid='id');
CREATE TRIGGER IF NOT EXISTS bookmarks_ai AFTER INSERT ON bookmarks BEGIN
INSERT INTO bookmarks_fts(rowid, title, notes) VALUES (new.id, new.title, new.notes);
END;
CREATE TRIGGER IF NOT EXISTS bookmarks_ad AFTER DELETE ON bookmarks BEGIN
INSERT INTO bookmarks_fts(bookmarks_fts, rowid, title, notes) VALUES ('delete', old.id, old.title, old.notes);
END;
`
var (
ErrNotFound = errors.New("bookmark not found")
ErrDuplicate = errors.New("bookmark already exists")
ErrBadURL = errors.New("url must be an absolute http or https address")
)
type Bookmark struct {
ID int64 `json:"id"`
URL string `json:"url"`
Title string `json:"title"`
Notes string `json:"notes,omitempty"`
Tags []string `json:"tags"`
}
type Store struct{ db *sql.DB }
var dbCounter atomic.Int64
// Open gives every call its own in-memory database, which is what keeps tests independent.
func Open() (*Store, error) {
db, err := sql.Open("sqlite", fmt.Sprintf("file:bm%d?mode=memory&cache=shared", dbCounter.Add(1)))
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // one writer; SQLite serialises anyway, and it keeps the in-memory database alive
if _, err := db.Exec(schema); err != nil {
return nil, err
}
return &Store{db}, nil
}
// NormalizeURL makes two spellings of the same page the same string, so the UNIQUE constraint means something.
func NormalizeURL(raw string) (string, error) {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", ErrBadURL
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Fragment = ""
if u.Path == "/" {
u.Path = ""
}
return u.String(), nil
}
func (s *Store) Add(ctx context.Context, b Bookmark) (Bookmark, error) {
norm, err := NormalizeURL(b.URL)
if err != nil {
return Bookmark{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return Bookmark{}, err
}
defer tx.Rollback()
res, err := tx.ExecContext(ctx, `INSERT INTO bookmarks (url, title, notes) VALUES (?, ?, ?)`, norm, b.Title, b.Notes)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed: bookmarks.url") {
return Bookmark{}, ErrDuplicate
}
return Bookmark{}, err
}
id, _ := res.LastInsertId()
for _, t := range cleanTags(b.Tags) {
if _, err := tx.ExecContext(ctx, `INSERT OR IGNORE INTO tags (name) VALUES (?)`, t); err != nil {
return Bookmark{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO bookmark_tags (bookmark_id, tag_id) SELECT ?, id FROM tags WHERE name = ?`, id, t); err != nil {
return Bookmark{}, err
}
}
if err := tx.Commit(); err != nil {
return Bookmark{}, err
}
return s.Get(ctx, id)
}
func cleanTags(in []string) []string {
seen := map[string]bool{}
var out []string
for _, t := range in {
t = strings.ToLower(strings.TrimSpace(t))
if t != "" && !seen[t] {
seen[t] = true
out = append(out, t)
}
}
sort.Strings(out)
return out
}
func (s *Store) Get(ctx context.Context, id int64) (Bookmark, error) {
var b Bookmark
err := s.db.QueryRowContext(ctx, `SELECT id, url, title, notes FROM bookmarks WHERE id = ?`, id).Scan(&b.ID, &b.URL, &b.Title, &b.Notes)
if errors.Is(err, sql.ErrNoRows) {
return Bookmark{}, ErrNotFound
}
if err != nil {
return Bookmark{}, err
}
b.Tags, err = s.tags(ctx, id)
return b, err
}
func (s *Store) tags(ctx context.Context, id int64) ([]string, error) {
rows, err := s.db.QueryContext(ctx, `SELECT t.name FROM tags t JOIN bookmark_tags bt ON bt.tag_id = t.id WHERE bt.bookmark_id = ? ORDER BY t.name`, id)
if err != nil {
return nil, err
}
defer rows.Close()
out := []string{}
for rows.Next() {
var n string
rows.Scan(&n)
out = append(out, n)
}
return out, rows.Err()
}
// List filters by tag and/or a full text query. Both are bound parameters; the FTS query is quoted so user input cannot be operator syntax.
func (s *Store) List(ctx context.Context, tag, q string, limit int) ([]Bookmark, error) {
query := `SELECT b.id FROM bookmarks b WHERE 1 = 1`
var args []any
if tag != "" {
query += ` AND EXISTS (SELECT 1 FROM bookmark_tags bt JOIN tags t ON t.id = bt.tag_id WHERE bt.bookmark_id = b.id AND t.name = ?)`
args = append(args, strings.ToLower(tag))
}
if q != "" {
query += ` AND b.id IN (SELECT rowid FROM bookmarks_fts WHERE bookmarks_fts MATCH ?)`
args = append(args, `"`+strings.ReplaceAll(q, `"`, `""`)+`"`)
}
query += ` ORDER BY b.id DESC LIMIT ?`
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
var ids []int64
for rows.Next() {
var id int64
rows.Scan(&id)
ids = append(ids, id)
}
rows.Close()
out := []Bookmark{}
for _, id := range ids {
b, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}
func (s *Store) Delete(ctx context.Context, id int64) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM bookmarks WHERE id = ?`, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}The pieces that deserve attention:
Foreign keys are opt-in. SQLite does not enforce foreign keys unless PRAGMA foreign_keys = ON is set, and the setting is per connection. The lab sets the pool to a single connection, so one pragma covers it. If you use more than one connection (for example separate read and write pools, which is a sound design for WAL mode), set the pragma on every connection, through the DSN parameter _pragma=foreign_keys(1) that modernc understands or a connection hook.
One database per Open. Open builds a DSN with an atomic counter, file:bm1?mode=memory&cache=shared, so each call creates a distinct named in-memory database. Without the unique name, two tests would share one database and their rows would collide. With cache=shared and a single connection, the database lives as long as the connection does. This is the same trick as in the SQLite memory article.
One connection. SetMaxOpenConns(1) is correct for the tests and a defensible starting point for a small service: SQLite allows one writer at a time anyway, and a single connection avoids "database is locked" surprises. For read-heavy production use, open a second pool with more connections for reads in WAL mode.
Normalising URLs. NormalizeURL lowercases the scheme and host, drops the fragment, treats / as the empty path, and rejects anything that is not an absolute http or https URL. This is what makes UNIQUE (url) mean "this page". Decide the rules deliberately: stripping the query string would merge pages that differ only by ?id=3, and keeping tracking parameters would split the same page into many. The version here keeps the query and removes only the fragment, which is the safest default.
Rejecting other schemes is a security measure as well as a quality rule. A bookmark whose URL is javascript:alert(1) rendered as a link in a front end is a stored XSS vector. Allow-list the schemes you accept at the API.
Transactional add. Add inserts the bookmark and its tags in one transaction. INSERT OR IGNORE INTO tags creates a tag once, and INSERT ... SELECT links it by name. If anything fails, defer tx.Rollback() undoes it all, so there is never a bookmark with half its tags.
Search. List builds the query from only the conditions that were asked for, always with bound parameters. The FTS text is wrapped in double quotes, with embedded quotes doubled. That turns user input into a single phrase and not FTS5 operator syntax: a user typing AND OR ( or a stray " would otherwise cause a syntax error. The test with q=%22 confirms the result is an empty list, not a 500.
The HTTP layer
package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
)
func NewHandler(s *Store) http.Handler {
mux := http.NewServeMux()
reply := func(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
fail := func(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
reply(w, http.StatusNotFound, map[string]string{"error": err.Error()})
case errors.Is(err, ErrDuplicate):
reply(w, http.StatusConflict, map[string]string{"error": err.Error()})
case errors.Is(err, ErrBadURL):
reply(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
default:
reply(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
id := func(r *http.Request) (int64, bool) {
n, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
return n, err == nil && n > 0
}
mux.HandleFunc("POST /bookmarks", func(w http.ResponseWriter, r *http.Request) {
var in Bookmark
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<15))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil || strings.TrimSpace(in.Title) == "" || len(in.Title) > 300 {
reply(w, http.StatusUnprocessableEntity, map[string]string{"error": "title is required (up to 300 characters)"})
return
}
b, err := s.Add(r.Context(), in)
if err != nil {
fail(w, err)
return
}
w.Header().Set("Location", "/bookmarks/"+strconv.FormatInt(b.ID, 10))
reply(w, http.StatusCreated, b)
})
mux.HandleFunc("GET /bookmarks", func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit < 1 || limit > 100 {
limit = 50
}
bs, err := s.List(r.Context(), r.URL.Query().Get("tag"), r.URL.Query().Get("q"), limit)
if err != nil {
fail(w, err)
return
}
reply(w, http.StatusOK, bs)
})
mux.HandleFunc("GET /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
n, ok := id(r)
if !ok {
reply(w, http.StatusBadRequest, map[string]string{"error": "id must be a positive integer"})
return
}
b, err := s.Get(r.Context(), n)
if err != nil {
fail(w, err)
return
}
reply(w, http.StatusOK, b)
})
mux.HandleFunc("DELETE /bookmarks/{id}", func(w http.ResponseWriter, r *http.Request) {
n, ok := id(r)
if !ok {
reply(w, http.StatusBadRequest, map[string]string{"error": "id must be a positive integer"})
return
}
if err := s.Delete(r.Context(), n); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
})
return mux
}
func main() {}This follows the same pattern as the todo API: decode strictly with a size cap, validate, call the store, map store errors to statuses in one fail function. Duplicates are 409, an unacceptable URL is 422, a missing bookmark is 404. The standard library mux with method patterns and PathValue is enough here, and the file has no third-party imports other than the driver.
The tests
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
type client struct {
t *testing.T
h http.Handler
}
func (c client) do(method, path, body string) (int, string) {
c.t.Helper()
rec := httptest.NewRecorder()
c.h.ServeHTTP(rec, httptest.NewRequest(method, path, strings.NewReader(body)))
return rec.Code, strings.TrimSpace(rec.Body.String())
}
func newClient(t *testing.T) client {
t.Helper()
s, err := Open()
if err != nil {
t.Fatal(err)
}
return client{t, NewHandler(s)}
}
func TestNormalizeURL(t *testing.T) {
for in, want := range map[string]string{
"https://Example.COM/": "https://example.com",
"https://example.com/a?b=1#section": "https://example.com/a?b=1",
" http://example.com/path ": "http://example.com/path",
"HTTPS://example.com/Path": "https://example.com/Path",
} {
got, err := NormalizeURL(in)
if err != nil || got != want {
t.Errorf("NormalizeURL(%q) = %q, %v; want %q", in, got, err, want)
}
}
for _, bad := range []string{"", "example.com", "ftp://example.com", "javascript:alert(1)", "http://"} {
if _, err := NormalizeURL(bad); err == nil {
t.Errorf("NormalizeURL(%q) should fail", bad)
}
}
}
func TestCreateAndDuplicates(t *testing.T) {
c := newClient(t)
code, body := c.do("POST", "/bookmarks", `{"url":"https://go.dev/doc/","title":"Go docs","tags":["Go","docs","go"]}`)
if code != 201 || !strings.Contains(body, `"tags":["docs","go"]`) {
t.Fatalf("create = %d %s", code, body)
}
// The same page spelled differently is a duplicate.
if code, _ := c.do("POST", "/bookmarks", `{"url":"HTTPS://GO.DEV/doc/#top","title":"again"}`); code != 409 {
t.Fatalf("duplicate = %d, want 409", code)
}
}
func TestValidation(t *testing.T) {
c := newClient(t)
for name, body := range map[string]string{
"missing title": `{"url":"https://example.com"}`,
"bad scheme": `{"url":"ftp://example.com","title":"x"}`,
"unknown field": `{"url":"https://example.com","title":"x","admin":true}`,
} {
if code, _ := c.do("POST", "/bookmarks", body); code != 422 {
t.Errorf("%s = %d, want 422", name, code)
}
}
}
func TestSearchAndTagFilter(t *testing.T) {
c := newClient(t)
c.do("POST", "/bookmarks", `{"url":"https://go.dev/blog/slog","title":"Structured logging with slog","notes":"log/slog tutorial","tags":["go","logging"]}`)
c.do("POST", "/bookmarks", `{"url":"https://sqlite.org/fts5.html","title":"SQLite FTS5","notes":"full text search","tags":["sqlite"]}`)
c.do("POST", "/bookmarks", `{"url":"https://example.com/go-tips","title":"Go tips","tags":["go"]}`)
count := func(path string) int {
code, body := c.do("GET", path, "")
var bs []Bookmark
json.Unmarshal([]byte(body), &bs)
if code != 200 {
t.Fatalf("%s = %d", path, code)
}
return len(bs)
}
for path, want := range map[string]int{
"/bookmarks": 3,
"/bookmarks?tag=go": 2,
"/bookmarks?tag=GO": 2,
"/bookmarks?tag=nope": 0,
"/bookmarks?q=slog": 1,
"/bookmarks?q=search": 1,
"/bookmarks?q=%22": 0, // a stray quote is data, not syntax
"/bookmarks?tag=go&q=logging": 1,
"/bookmarks?limit=1": 1,
} {
if got := count(path); got != want {
t.Errorf("GET %s returned %d bookmarks, want %d", path, got, want)
}
}
}
func TestDeleteRemovesFromSearch(t *testing.T) {
c := newClient(t)
c.do("POST", "/bookmarks", `{"url":"https://example.com/a","title":"Findable","tags":["x"]}`)
if code, _ := c.do("DELETE", "/bookmarks/1", ""); code != 204 {
t.Fatalf("delete = %d", code)
}
if code, _ := c.do("DELETE", "/bookmarks/1", ""); code != 404 {
t.Fatalf("second delete = %d", code)
}
code, body := c.do("GET", "/bookmarks?q=Findable", "")
if code != 200 || body != "[]" {
t.Fatalf("search after delete = %d %s", code, body)
}
if code, _ := c.do("GET", "/bookmarks/abc", ""); code != 400 {
t.Fatalf("bad id = %d", code)
}
}The suite is built around a small client helper that sends a request into the handler and returns the status and body. Each test calls newClient, which opens a new in-memory database, so tests are independent by construction and could be marked parallel.
TestNormalizeURLis a pure table test, including the failing inputs.TestCreateAndDuplicateschecks that tags come back lowercased, de-duplicated and sorted, and that a differently spelled URL for the same page is a 409.TestValidationcovers missing titles, bad schemes and unknown fields.TestSearchAndTagFilterseeds three bookmarks and runs a table of paths against expected counts: unfiltered, by tag in both cases, by an unknown tag, by words in title and notes, by a stray quote, by tag and text together, and with a limit.TestDeleteRemovesFromSearchdeletes a bookmark and checks it no longer appears in search results, which verifies the FTS delete trigger, and checks the 404 and 400 paths.
go test -race -count=1 -v . | grep -E '^(--- | --- |ok|FAIL|PASS)' | sed -E 's/ \([0-9.]+s\)//'--- PASS: TestNormalizeURL
--- PASS: TestCreateAndDuplicates
--- PASS: TestValidation
--- PASS: TestSearchAndTagFilter
--- PASS: TestDeleteRemovesFromSearch
PASS
ok example.test/lab 1.480sAll five pass under the race detector, in about a second. The longest are the ones that create several rows, at a few tens of milliseconds, which is fast enough to run on every save.
Operating SQLite in production
The tests use memory. A deployed service uses a file, and a few settings matter.
WAL mode. PRAGMA journal_mode = WAL lets readers proceed while a writer writes and is the right default for a server. It is persistent in the file, so it needs setting once.
Busy timeout. PRAGMA busy_timeout = 5000 makes a connection wait up to five seconds for a lock instead of failing immediately with "database is locked".
Synchronous. synchronous = NORMAL with WAL trades a tiny durability window for speed and is common; FULL is the safe default and slower.
Transactions. Begin write transactions with BEGIN IMMEDIATE when they will write, to take the write lock up front and avoid mid-transaction lock upgrade failures. With database/sql, this is a driver option (_txlock=immediate in the DSN, for drivers that support it).
Backups. Use VACUUM INTO 'backup.db' or the online backup API for a consistent copy while the service runs. Copying a live WAL database file by hand can capture an inconsistent state. Replication tools such as Litestream stream the WAL to object storage; I did not run one here.
Migrations. Apply versioned migrations at start-up with PRAGMA user_version or a migration tool. Note that SQLite's ALTER TABLE is limited, and many changes mean creating a new table and copying.
Limits. SQLite handles a lot more than most people expect, many thousands of writes per second on decent storage, and read concurrency is excellent. Its limits are a single writer, a single machine and no network access. When you need several application servers writing at once, a client-server database is the better tool. Choosing between SQLite and PostgreSQL discusses where that line sits.
Ranking search results
The list handler sorts by id, newest first, which is predictable and not what a search box wants. FTS5 provides a built-in ranking function, bm25, which scores a row by how rare the matched terms are and how often they appear. To use it, query the FTS table directly and order by the rank column:
SELECT b.id FROM bookmarks_fts f
JOIN bookmarks b ON b.id = f.rowid
WHERE bookmarks_fts MATCH ?
ORDER BY bm25(bookmarks_fts, 10.0, 1.0) -- weight the title ten times the notes
LIMIT ?Lower scores are better in bm25, so ascending order puts the best match first. The weights are given per column in the order the columns were declared. Combine it with the tag filter by joining on the tag condition. Keep the id order for the no-query case. Whenever you change ranking, keep a fixed set of queries and expected top results in a test, so the effect of a change is visible in the diff and not in a bug report.
Prefix queries are a one-character change: "golan"* matches golang. They suit search-as-you-type but need the prefix option on the table to be fast. The tokenizer decides what a word is. The default unicode61 tokenizer folds case and strips diacritics, and porter adds English stemming. Choose when you create the table, since changing it means rebuilding the index.
Separate read and write connections
A single connection, as in the lab, is the simplest correct setup. As load grows, the usual refinement is two pools opened on the same file in WAL mode: a write pool limited to one connection, and a read pool with several.
write, _ := sql.Open("sqlite", "file:bookmarks.db?_pragma=journal_mode(wal)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_txlock=immediate")
write.SetMaxOpenConns(1)
read, _ := sql.Open("sqlite", "file:bookmarks.db?mode=ro&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)")
read.SetMaxOpenConns(8)Reads proceed in parallel and never block the writer in WAL mode, and writes are serialised in your process, where you can queue them, instead of fighting for the lock inside SQLite. Route methods that only read to the read pool. The Store in the lab would hold two *sql.DB fields and each method would pick the right one. I did not benchmark this arrangement here; measure with your own workload before adding the complexity.
Design decisions to revisit
Identifiers. Integer ids are fine for a private tool. For a public API, expose a random public id alongside the internal integer so that ids cannot be enumerated (IDOR).
Timestamps. The schema has none, to keep the lab short. Add created_at as ISO 8601 text in UTC (strftime('%Y-%m-%dT%H:%M:%fZ','now') as the default); SQLite has no native datetime type, and text in a sortable format is the most portable choice.
Pagination. The list returns up to 100 items with no cursor. Add keyset pagination on the id, as in the todo API, before the collection grows past what one response should carry.
Tag management. Tags are created implicitly on first use and never deleted. A periodic cleanup of unused tags, or a DELETE on a tag with reference counts, keeps the table tidy. Renaming a tag is a single UPDATE because bookmarks reference tags by id, which is a payoff of the join table.
Input size. The handler caps the body at 32 KiB, the title at 300 characters and nothing else. Cap the number of tags and the length of each, or one request can create thousands of tag rows.
Importing existing bookmarks
Most people arrive with bookmarks in a browser. Browsers export a Netscape-format HTML file: nested <DL> lists with <A HREF="..." ADD_DATE="..." TAGS="..."> elements. Parsing it with golang.org/x/net/html and posting each link through Store.Add reuses all the validation. Import in one transaction per batch of a few hundred, skip duplicates quietly (count them in the report), and process URLs through NormalizeURL so repeated imports are idempotent. That last property is worth testing: import the same file twice and expect the second run to add nothing.
Where to take it
Add PUT/PATCH for editing a bookmark, with an update trigger on the FTS table. Add ranking with bm25() to order search results by relevance. Add authentication and a user_id column so each user sees only their own bookmarks, using the query pattern in multi-tenant APIs. Import and export in the Netscape bookmarks HTML format. Fetch page titles automatically, with the SSRF-safe client since the URL is user input. The SQLite documentation for FTS5 covers ranking, prefix queries and tokenizers, and the modernc.org/sqlite package documents the DSN options used above.


