For a while, getting into my own blog admin meant typing the URL, then adding /admin to the end. Every single time. It's a small thing, but small frictions are the ones you feel most often, and this was the one standing between me and actually writing.
So I spent an evening fixing it. Not just the entrance, but the whole writing surface behind it. The editor worked, but it was a plain form: a title field, a textarea, a save button. Functional, and a little lifeless. I wanted it to feel like a tool I'd reach for, not a form I had to fill in.
The guiding idea, borrowed from someone who said it better than I can:
Simplicity is not the absence of clutter, that's a consequence of simplicity. Simplicity is somehow essentially describing the purpose and place of an object and product.
That framed every decision below. Not "what can I add," but "what can I take away until only the writing is left."
A Hidden Entrance
The first instinct was to add a link somewhere β a discreet "admin" in the footer. But a visible link is clutter for every visitor who will never use it, and an invitation to the few who shouldn't. The right answer was no visible affordance at all.
Instead, the site listens. Type a short word anywhere on the page β not focused in any field β and it quietly takes you to the portal.
const SECRET = "topsecret";
const onKeyDown = (e: KeyboardEvent) => {
// Never hijack real typing β ignore inputs, textareas, editable nodes.
const el = e.target as HTMLElement | null;
if (el && (el.isContentEditable || el.tagName === "INPUT" ||
el.tagName === "TEXTAREA" || el.tagName === "SELECT")) return;
if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return;
buffer = (buffer + e.key.toLowerCase()).slice(-SECRET.length);
if (buffer === SECRET) router.push("/admin");
};tsxThe whole thing is a null-rendering component mounted in the root layout. There's nothing to see, nothing to discover by accident, and it stays out of the way while I'm typing a post. It's invisible until the one person who knows it's there asks for it.
A combo like βK would have worked, but it competes with browser and OS shortcuts and needs a visible hint to be discoverable. A typed word collides with nothing and hides completely. For a single-user door, hidden beats fast.
Removing the Anxiety of Saving
Two changes here, both about the same feeling: the quiet worry that you'll lose what you wrote.
The first is muscle memory. Every writer reaches for βS without thinking. Before, that summoned the browser's "save this page" dialog β a jarring intrusion. Now it just saves the post.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
formRef.current?.requestSubmit();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);tsxThe second is a safety net. As I type, the draft quietly persists to localStorage. Close the tab, lose Wi-Fi, hit back by accident β when I return, the editor picks up exactly where I left off, with one small line offering to discard it if I don't want it.
The best safety net is the one you never notice catching you.
No "are you sure you want to leave" pop-ups, no autosave spinner. The work is just there when you come back, and the net clears itself the moment a real save succeeds.
Making the Page Feel Like an Editor
The body was a plain textarea. It worked, but I had no sense of where I was in a long post, and markdown sat there as flat, uniform text. I wanted line numbers, the line I'm on, and a bit of colour to tell structure from prose.
The trick is to layer a coloured, syntax-highlighted <div> behind a transparent textarea. The textarea still does all the real work β the caret, selection, image paste-and-drop β but its text is invisible, so what you see is the coloured layer underneath showing through.
.editor-input {
color: transparent; /* the real text, made invisible */
caret-color: var(--color-accent);
}
.editor-layer {
position: absolute;
inset: 0;
pointer-events: none; /* the coloured layer, behind */
}cssThe one hard rule: both surfaces must share identical metrics β font, size, line-height, padding, wrapping. And the highlighter is only ever allowed to colour characters, never change their weight or style. A faux-bold heading would widen its glyphs by a pixel and the two layers would drift out of alignment, ghosting the text. So headings, bold, and italic are all conveyed with colour alone.
A gutter down the left numbers each line and highlights the one my caret sits on, and a small status bar underneath reports the essentials:
Ln 24, Col 7 Β· 86 lines Β· 1,240 words Β· Markdown
That's the context I was missing β where am I, and how big is this thing β without a single extra panel.
The Small Motions
Two details that do nothing functional and everything for feel.
The publish control is a draft / live toggle. It used to swap states instantly. Now a single pill slides between the two β a soft 280ms ease β so the state change is something you feel, not just see. The fix that finally made it animate was telling the browser to interpolate between two explicit transforms instead of from nothing:
.state-toggle-thumb {
transform: translateX(0); /* explicit start */
transition: transform 0.28s cubic-bezier(0.32, 0.72, 0, 1);
}
.state-toggle[data-active="live"] .state-toggle-thumb {
transform: translateX(100%);
}cssAnd the save button itself grows and shrinks as its label changes β "Save" to the wider "Publish," then stretching to "Publishingβ¦" mid-save. Since width: auto can't be animated, it measures the label's real width and sets a number the browser can transition. Both motions share the same easing curve, so they feel like they come from the same hand.
The Bug I Found by Writing This
Once I added pagination to the post list, I noticed page two loaded slower than page one. The cause was a good lesson in caching: page one was held in the router's memory from my first visit, while page two was a fresh request β and every fresh request re-downloaded and re-parsed every post from storage just to list their titles.
The fix was to cache the parsed list and let my own edits invalidate it:
const loadAllPosts = unstable_cache(loadAllPostsUncached, ["all-posts"], {
tags: [POSTS_CACHE_TAG],
revalidate: 60,
});tsWhen I save, delete, or migrate a post, the admin action calls updateTag(POSTS_CACHE_TAG), which purges that cache immediately with read-your-own-writes semantics. So the list is always fast and always fresh β I see my changes the instant I make them, and everyone else reads from the cache.
If a navigation feels slow, ask whether it's the work or the cache. Here the page-number difference was a red herring β the real story was "visited and cached" versus "fetched fresh."
What Changed
None of this is a feature, exactly. There's no new button to point at, no setting to flip. The entrance disappeared, the fear of losing work disappeared, the markup gained colour, and a couple of controls learned to move. Each change took something away β a URL to remember, a pop-up, a flat wall of text β and what's left is closer to just writing.
This very post is the first thing I wrote in it. That feels like the right test.