We build web applications today as if bandwidth and CPU cycles are infinite. A simple page to post 280 characters often pulls down megabytes of JavaScript, runs complex hydration cycles, and chokes on slow mobile networks. Even with modern techniques like Next.js 15 Partial Prerendering designed to optimize delivery, the client-side overhead remains high. I wanted something different. I wanted a microblogging platform that felt like writing in a terminal: fast, quiet, and independent of client-side scripting.
That is how textlog started. It is an open-source, self-hostable microblogging platform. It uses zero JavaScript on the client. It runs on a tiny virtual private server with less than 20 megabytes of RAM, and it stays fast even on a flaky 2G connection.
Here is how we designed and built the architecture of textlog.
The Philosophy of Quiet Software
Modern social platforms want your constant attention. They use infinite scroll, real-time web sockets, and push notifications to keep you hooked. These features require massive amounts of client-side code.
textlog goes the opposite way. We call it "quiet software." It does not push updates to you. You pull updates when you want them. The platform does not track your cursor or run client-side analytics.
Eliminating JavaScript simplifies the stack. You do not need to worry about build steps, transpilers, npm vulnerabilities, or hydration bugs. The browser receives plain HTML and CSS, renders it instantly, and waits for the next user action.
The Stack: Go, SQLite, and HTML
The backend needs to match the simplicity of the frontend. We chose Go for the application logic and SQLite for storage.
Go compiles to a single binary. It starts instantly, has an excellent standard library for HTTP handling, and uses very little memory. We avoided heavy frameworks. The routing uses Go's standard net/http multiplexer, and templates use the built-in html/template package.
SQLite is the database. For a single-user or small-group microblog, you do not need a separate database server like PostgreSQL. SQLite stores everything in a single file on disk. We enabled Write-Ahead Logging (WAL) mode to handle concurrent reads and writes without blocking.
Here is the basic directory structure of the project:
textlog/
├── cmd/
│ └── server/
│ └── main.go
├── internal/
│ ├── database/
│ │ ├── db.go
│ │ └── schema.sql
│ ├── handler/
│ │ ├── handlers.go
│ │ └── middleware.go
│ └── models/
│ └── models.go
├── ui/
│ ├── html/
│ │ ├── base.html
│ │ ├── feed.html
│ │ └── post.html
│ └── static/
│ └── style.css
├── go.mod
└── textlog.db
This layout keeps the code organized without nesting packages too deeply.
Designing Interactions Without JavaScript
When you cannot use fetch() or Axios, you have to rely on native browser behaviors. Every user interaction in textlog runs through standard native HTML form controls and HTTP redirects.
Posting and Replying
Creating a post requires a <form> element with a POST method.
<form action="/post" method="POST">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<textarea name="content" placeholder="What is on your mind?" required maxlength="500"></textarea>
<button type="submit">Publish</button>
</form>When the user clicks "Publish", the browser submits the form data using application/x-www-form-urlencoded. The Go server processes the request, validates the input, writes the post to the SQLite database, and sends a 303 See Other redirect back to the home feed.
This redirect is important. It prevents the double-submit problem if the user refreshes the page after posting.
Here is the Go handler that processes the post creation:
func (app *Application) CreatePostHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed)
return
}
content := r.PostFormValue("content")
if len(content) == 0 || len(content) > 500 {
http.Error(w, "Invalid content length", http.StatusBadRequest)
return
}
userID := app.GetUserIDFromSession(r)
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
postID := generateULID()
err := app.DB.InsertPost(postID, userID, content)
if err != nil {
http.Error(w, "Failed to save post", http.StatusInternalServerError)
return
}
w.Header().Set("Location", "/")
w.WriteHeader(http.StatusSeeOther)
}Handling Likes and Deletions
In a typical single-page app, clicking a "like" button triggers an asynchronous API call that updates the UI state inline. In textlog, we use small, styled forms for these actions.
<form action="/post/{{.ID}}/like" method="POST" class="inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="link-button">
Like ({{.LikesCount}})
</button>
</form>The CSS rules make the button look like a simple text link:
.inline-form {
display: inline;
margin: 0;
padding: 0;
}
.link-button {
background: none;
border: none;
color: var(-color-text-muted);
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
}
.link-button:hover {
color: var(-color-text);
}When clicked, the form submits, the server increments the count in the database, and redirects the user back to the page they came from. We read the Referer header to determine where to send the user. If the header is missing, we default to the main feed.
CSS-Only State Toggles
Some UI elements usually need JavaScript to show and hide, like settings menus or reply threads. We build these using the checkbox hack.
<div class="menu-container">
<input type="checkbox" id="menu-toggle" class="toggle-checkbox">
<label for="menu-toggle" class="menu-button">Menu</label>
<nav class="dropdown-menu">
<a href="/profile">Profile</a>
<a href="/settings">Settings</a>
<a href="/logout">Logout</a>
</nav>
</div>The CSS controls the visibility based on the checked state of the hidden input:
.toggle-checkbox {
position: absolute;
opacity: 0;
z-index: -1;
}
.dropdown-menu {
display: none;
}
.toggle-checkbox:checked ~ .dropdown-menu {
display: block;
}This pattern works on every modern browser, handles keyboard navigation through the label, and requires zero client-side processing.
The Database Schema
We designed the SQLite database to be flat and fast. The schema avoids deep joins.
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
content TEXT NOT NULL,
parent_id TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(parent_id) REFERENCES posts(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS likes (
user_id TEXT NOT NULL,
post_id TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, post_id),
FOREIGN KEY(user_id) REFERENCES users(id),
FOREIGN KEY(post_id) REFERENCES posts(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_posts_parent_id ON posts(parent_id);We use ULIDs (Universally Unique Lexicographically Sortable Identifiers) instead of auto-incrementing integers for IDs. ULIDs are sortable by generation time, which allows us to paginate posts efficiently without relying on heavy OFFSET queries.
Optimizing SQLite for Concurrency
By default, SQLite locks the database file during writes. We change this behavior in our database connection pool settings:
db, err := sql.Open("sqlite3", "textlog.db?_journal_mode=WAL&_sync=NORMAL&_busy_timeout=5000")_journal_mode=WAL: Enables Write-Ahead Logging. Readers do not block writers, and writers do not block readers._sync=NORMAL: Reduces disk writes by syncing the WAL file at critical moments rather than every single write. This is safe enough for a microblog._busy_timeout=5000: Tells SQLite to wait up to 5 seconds for a lock to clear before returning an error.
These settings let our Go application handle hundreds of concurrent requests without database lock errors.
CSS and Theme Architecture
We wanted to support system dark mode without adding a toggle button that requires JavaScript. CSS media queries make this straightforward.
:root {
-color-bg: #ffffff;
-color-text: #1a1a1a;
-color-text-muted: #666666;
-color-border: #e0e0e0;
-color-accent: #0066cc;
}
@media (prefers-color-scheme: dark) {
:root {
-color-bg: #121212;
-color-text: #e0e0e0;
-color-text-muted: #888888;
-color-border: #2d2d2d;
-color-accent: #4da6ff;
}
}
body {
background-color: var(-color-bg);
color: var(-color-text);
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
margin: 0 auto;
max-width: 600px;
padding: 1rem;
}This approach respects the user's operating system preferences automatically. It requires no local storage configuration, no flashing backgrounds on initial page load, and no client-side scripting.
Offline-Friendly Sync and Feed Protocol
One benefit of a no-JS site is that browsers know how to cache it natively. We use Cache-Control headers to let the browser keep static assets on disk indefinitely.
func CacheMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/static/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
next.ServeHTTP(w, r)
})
}For offline writing, textlog supports simple local drafting. Since there is no JavaScript to run a service worker, we rely on standard HTML5 form submission behavior. If a submit fails, many browsers preserve the input data. But we also provide a fallback. Users can export their feeds as an RSS feed or a JSON file.
The platform exposes a standard RSS feed at /feed.xml. Any feed reader can subscribe to it. This design makes textlog a good citizen of the open web. It does not lock your content inside a silo.
Here is the Go handler that generates the RSS feed:
func (app *Application) FeedHandler(w http.ResponseWriter, r *http.Request) {
posts, err := app.DB.GetLatestPosts(50)
if err != nil {
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/rss+xml; charset=utf-8")
fmt.Fprintf(w, `<?xml version="1.0" encoding="UTF-8" ?>`+"\n")
fmt.Fprintf(w, `<rss version="2.0">`+"\n")
fmt.Fprintf(w, "<channel>\n")
fmt.Fprintf(w, "<title>textlog Feed</title>\n")
fmt.Fprintf(w, "<link>https://textlog.me</link>\n")
fmt.Fprintf(w, "<description>A quiet microblog</description>\n")
for _, post := range posts {
fmt.Fprintf(w, "<item>\n")
fmt.Fprintf(w, " <description>%s</description>\n", html.EscapeString(post.Content))
fmt.Fprintf(w, " <link>https://textlog.me/post/%s</link>\n", post.ID)
fmt.Fprintf(w, " <pubDate>%s</pubDate>\n", post.CreatedAt.Format(time.RFC1123Z))
fmt.Fprintf(w, " <guid>https://textlog.me/post/%s</guid>\n", post.ID)
fmt.Fprintf(w, "</item>\n")
}
fmt.Fprintf(w, "</channel>\n")
fmt.Fprintf(w, "</rss>\n")
}This handler runs in milliseconds and uses almost no memory.
Security Without JavaScript
Handling security without client-side scripts requires care, especially concerning Cross-Site Request Forgery (CSRF). While a modern Next.js authentication checklist relies on client-side state and token rotation, we must handle everything server-side.
When a JavaScript app makes requests, it often uses custom headers like Authorization: Bearer <token> which are not automatically sent by the browser. HTML forms, however, send cookies automatically. This makes them vulnerable to CSRF.
We solve this by generating a unique CSRF token for each session. We store the token in the session database (or an encrypted cookie) and render it inside a hidden field in every form. When the form is submitted, our middleware compares the form token with the session token.
func (app *Application) VerifyCSRF(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
err := r.ParseForm()
if err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
return
}
sessionToken := app.GetSessionToken(r)
formToken := r.PostForm.Get("csrf_token")
if sessionToken == "" || formToken == "" || sessionToken != formToken {
http.Error(w, "Forbidden - CSRF token mismatch", http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}This middleware protects all write actions without needing complex setup on the client side.
Performance Metrics
To test the architecture, we ran a performance benchmark using wrk on a single-core VPS with 512 megabytes of RAM.
During a test with 100 concurrent connections over 30 seconds, the server handled over 1,200 requests per second. The average response time was 8.2 milliseconds.
Here is the payload breakdown for the main feed page containing 20 posts:
- HTML Document: 4.8 kilobytes
- CSS Stylesheet: 1.2 kilobytes
- Total Page Weight: 6.0 kilobytes
A page size of 6 kilobytes means the entire site loads within a single TCP round trip. The browser parses the document and renders it on screen before a modern framework would even finish downloading its runtime bundle.
Memory usage for the Go process stayed stable at 14 megabytes under load.
The Constraints of HTML-Only Design
Building textlog taught us that constraints can make software better. When you cannot use JavaScript, you stop trying to build complex, flashy features. You focus on readability and speed.
We had to give up some features. There is no live character counter in the textarea, so we use the HTML maxlength attribute instead. If a user exceeds the limit, the browser stops them from typing. We do not have instant notifications, so users check their notifications tab manually.
These tradeoffs make the platform simpler to maintain and more reliable to run. The web was built on documents and forms. By returning to those fundamentals, we built a platform that will likely work without updates for the next decade.



