Long-form Vue 3 article template with hand-tuned Tailwind typography, a scroll-aware table of contents, code blocks, callouts, author box and related posts.
A reading page for serious long-form writing: engineering deep dives, guides and essays. It pairs a generous article column with a sticky table of contents that highlights the section you are reading, a reading progress bar in the header and a share rail on wide screens.
The typography is written with plain Tailwind utilities rather than the typography plugin. All the paragraph, heading, list, quote and inline-code styles live in src/prose.ts, so adjusting the rhythm of the whole article means editing one small file. Code blocks use a tiny dependency-free highlighter and have a copy button.
On phones the table of contents becomes a collapsible panel that shows the current section, and the diagram, code and callouts all adapt to narrow widths. Replace the article body in ArticleBody.vue and the entries in src/data.ts to publish your own post.
Article hero, body, author box and related posts
Sticky table of contents with scroll-spy highlighting
Reading progress bar and share rail
Code block with syntax colours and copy button, note and warning callouts
Utility-class typography in one file, light and dark mode
<script setup lang="ts">import { syncSnippet } from '../data'import { prose } from '../prose'import ArticleCallout from './ArticleCallout.vue'import CodeBlock from './CodeBlock.vue'</script><template> <div> <p :class="prose.lead"> Last spring, one in every forty edits made in Kestrel’s mobile app never reached the server. Nobody lost data outright, but people noticed the gaps, and they stopped trusting the app in the places they needed it most: basements, trains and job sites. This is how we fixed it. </p> <h2 id="why-offline" tabindex="-1" :class="prose.h2">Why offline-first is worth it</h2> <p :class="prose.p"> The old client treated the network as the source of truth. Every tap made a request, and if the request failed we showed a toast and hoped the user would try again. That model is simple to reason about, but it pushes all of the hard work onto the person holding the phone. </p> <p :class="prose.p"> Offline-first flips that around. The device writes locally first, shows the result immediately and syncs in the background. The interface stays fast on good connections and <strong :class="prose.strong">keeps working</strong> on bad ones. The cost is that the client and server can now disagree, and you need a principled way to bring them back together. </p> <h2 id="data-model" tabindex="-1" :class="prose.h2">A log, not a snapshot</h2> <p :class="prose.p"> Our first instinct was to sync whole records: send the latest version of a task and let the server keep the newest one. It fails the moment two people edit different fields of the same record. Whoever syncs last silently overwrites the other person’s change. </p> <p :class="prose.p"> Instead, every edit becomes a small <code :class="prose.code">Operation</code>: which entity, which field, the new value and a clock. Devices append operations to a local queue, and the server applies them in clock order. Records are just the result of replaying the log. </p> <CodeBlock :code="syncSnippet" filename="sync/queue.ts" /> <p :class="prose.p"> Two properties make this work. Operation ids are generated on the device, so the server can recognise a batch it has already seen. And the clock is a <a href="#" :class="prose.a">hybrid logical clock</a>, which gives us a total order that roughly matches wall time without trusting anyone’s system clock. </p> <figure class="my-10"> <div class="rounded-2xl border border-gray-200 bg-gray-50 p-4 sm:p-8 dark:border-white/10 dark:bg-white/3"> <svg viewBox="0 0 560 190" class="w-full" role="img" aria-labelledby="diagram-title"> <title id="diagram-title">Two devices append operations that the server merges into one ordered log</title> <g font-family="ui-sans-serif, system-ui" font-size="12" class="fill-gray-500 dark:fill-gray-400"> <text x="0" y="36">Phone</text> <text x="0" y="156">Laptop</text> <text x="372" y="66">Server log</text> </g> <g class="stroke-gray-300 dark:stroke-white/15" stroke-width="2" fill="none"> <path d="M60 32 H 270 C 310 32, 312 92, 344 92" /> <path d="M60 152 H 270 C 310 152, 312 92, 344 92" /> </g> <g> <rect x="70" y="18" width="54" height="28" rx="8" class="fill-teal-500" /> <rect x="140" y="18" width="54" height="28" rx="8" class="fill-teal-500" /> <rect x="210" y="18" width="54" height="28" rx="8" class="fill-teal-500/40" /> <rect x="100" y="138" width="54" height="28" rx="8" class="fill-sky-500" /> <rect x="180" y="138" width="54" height="28" rx="8" class="fill-sky-500/40" /> </g> <g font-family="ui-monospace, monospace" font-size="11" fill="#fff" text-anchor="middle"> <text x="97" y="36">op 1</text><text x="167" y="36">op 3</text><text x="237" y="36">op 5</text> <text x="127" y="156">op 2</text><text x="207" y="156">op 4</text> </g> <circle cx="350" cy="92" r="7" class="fill-white stroke-teal-500 dark:fill-gray-950" stroke-width="3" /> <g font-family="ui-monospace, monospace" font-size="11" text-anchor="middle"> <template v-for="n in 5" :key="n"> <rect :x="372 + (n - 1) * 36" y="78" width="30" height="28" rx="6" :class="n % 2 ? 'fill-teal-500' : 'fill-sky-500'" /> <text :x="387 + (n - 1) * 36" y="96" fill="#fff">{{ n }}</text> </template> </g> </svg> </div> <figcaption :class="prose.figcaption">Operations from each device are interleaved by their hybrid clock, so every replica replays them in the same order.</figcaption> </figure> <h2 id="conflicts" tabindex="-1" :class="prose.h2">Resolving conflicts deterministically</h2> <p :class="prose.p"> With a total order, most conflicts disappear: two edits to the same field resolve to the one with the later clock, on every device, every time. We call this last-writer-wins per field, and it covers about 95% of what people actually do. </p> <p :class="prose.p">The remaining cases needed their own rules:</p> <ul :class="prose.ul"> <li><strong :class="prose.strong">Lists</strong> such as checklist items use fractional indexes, so two people inserting at once both keep their item.</li> <li><strong :class="prose.strong">Counters</strong> sync as increments rather than totals, which means offline additions add up instead of replacing each other.</li> <li><strong :class="prose.strong">Deletes</strong> leave a tombstone for 30 days, so a late edit to a deleted task cannot bring it back from the dead.</li> </ul> <ArticleCallout title="Deterministic beats clever"> Every merge rule runs on the server and on each device, and all three must reach the same answer from the same log. If a rule depends on anything outside the log, like the current time or a feature flag, it will eventually diverge. </ArticleCallout> <h2 id="transport" tabindex="-1" :class="prose.h2">Retries, backoff and idempotency</h2> <p :class="prose.p"> Once the server ignores duplicate operation ids, retrying becomes boring, which is exactly what you want. The client flushes in batches of up to 100, waits with exponential backoff and jitter after a failure, and flushes immediately when the OS reports connectivity has returned. </p> <blockquote :class="prose.blockquote"> “The best retry logic is the one you never have to think about, because sending the same thing twice is harmless.” </blockquote> <p :class="prose.p"> We also cap the queue. A device that has been offline for weeks uploads its backlog in pages, with a small progress indicator, instead of one enormous request that times out on a slow connection. </p> <ArticleCallout tone="warning" title="Watch your batch size"> Batches that are too large turn a single bad operation into a failed upload for everything behind it. We validate each operation individually and return a per-id result, so one malformed edit never blocks the rest. </ArticleCallout> <h2 id="testing" tabindex="-1" :class="prose.h2">Testing with a hostile network</h2> <p :class="prose.p"> Unit tests told us the merge rules were correct. They did not tell us what happens when a response is lost after the server has committed it. For that we built a small simulation harness that runs three fake devices against a real server and a proxy that misbehaves on purpose. </p> <ol :class="prose.ol"> <li>Drop, delay or duplicate a random 20% of requests and responses.</li> <li>Skew each device’s clock by up to ten minutes in either direction.</li> <li>Kill and restart a device in the middle of a flush.</li> <li>After every run, assert that all replicas and the server hold identical data.</li> </ol> <p :class="prose.p"> The harness runs a few thousand randomised sessions on every pull request that touches sync. It found four real bugs in its first week, including one where a restarted device replayed its queue in the wrong order. </p> <h2 id="takeaways" tabindex="-1" :class="prose.h2">What we would do again</h2> <p :class="prose.p"> Six months in, lost edits went from one in forty to zero we can find, and the app feels faster everywhere, not just offline. If you are starting a similar project, these are the decisions we would make again without hesitation: </p> <ul :class="prose.ul"> <li>Model changes as operations with device-generated ids from day one.</li> <li>Pick one ordering primitive and use it everywhere.</li> <li>Keep merge rules pure and share the implementation between client and server.</li> <li>Invest in a chaos harness before you need it, not after the first incident.</li> </ul> <p :class="prose.p"> If you have questions or war stories of your own, we would love to hear them. Our sync team hosts office hours on the first Thursday of every month. </p> </div></template>
export interface TocItem { id: string title: string}export interface RelatedPost { title: string category: string readMinutes: number /** Tailwind gradient classes for the drawn cover. */ gradient: string shape: 'circle' | 'bars' | 'grid'}export const article = { category: 'Engineering', title: 'Offline-first sync without the heartache', subtitle: 'How we rebuilt Kestrel’s sync engine around an operation log, deterministic merges and a test harness that lies about the network on purpose.', date: '2026-09-22', readMinutes: 12, author: { name: 'Priya Natarajan', initials: 'PN', role: 'Principal Engineer, Sync', bio: 'Priya leads the sync team at Kestrel. Before that she spent six years building field-data apps that had to work on a mountain with one bar of signal. She writes about distributed systems for people who would rather be shipping.', },}export const toc: TocItem[] = [ { id: 'why-offline', title: 'Why offline-first is worth it' }, { id: 'data-model', title: 'A log, not a snapshot' }, { id: 'conflicts', title: 'Resolving conflicts deterministically' }, { id: 'transport', title: 'Retries, backoff and idempotency' }, { id: 'testing', title: 'Testing with a hostile network' }, { id: 'takeaways', title: 'What we would do again' },]export const related: RelatedPost[] = [ { title: 'Hybrid logical clocks, explained with a whiteboard', category: 'Engineering', readMinutes: 8, gradient: 'from-teal-500 to-cyan-600', shape: 'circle' }, { title: 'Measuring sync latency from the user’s point of view', category: 'Data', readMinutes: 6, gradient: 'from-slate-700 to-teal-600', shape: 'bars' }, { title: 'The small UI details that make offline feel normal', category: 'Design', readMinutes: 5, gradient: 'from-emerald-400 to-teal-600', shape: 'grid' },]export const syncSnippet = `// Every change is an operation with a stable id.export interface Operation { id: string // uuid, generated on the device entity: string field: string value: unknown clock: HybridClock // wall time + counter + device id}export async function flush(queue: OperationQueue, api: SyncApi) { const batch = queue.peek(100) if (batch.length === 0) return // The server ignores ids it has already applied, // so retrying a batch is always safe. const { applied } = await api.push(batch) queue.remove(applied)}`export function formatDate(iso: string): string { return new Date(`${iso}T12:00:00`).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
export type TokenKind = 'keyword' | 'string' | 'comment' | 'number' | 'type' | 'fn' | 'plain'export interface Token { kind: TokenKind text: string}const KEYWORDS = new Set([ 'const', 'let', 'var', 'function', 'return', 'if', 'else', 'for', 'of', 'in', 'while', 'await', 'async', 'export', 'import', 'from', 'type', 'interface', 'new', 'throw', 'try', 'catch', 'class', 'extends', 'true', 'false', 'null', 'undefined',])const PATTERN = /(\/\/.*$)|('(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`)|(\b\d[\d_.]*\b)|([A-Za-z_$][\w$]*)/g/** * A deliberately tiny TypeScript highlighter: enough for blog snippets, * with no dependencies and no v-html. Returns one token array per line. */export function highlight(source: string): Token[][] { return source.replace(/\n$/, '').split('\n').map((line) => { const tokens: Token[] = [] let last = 0 for (const match of line.matchAll(PATTERN)) { const index = match.index ?? 0 if (index > last) tokens.push({ kind: 'plain', text: line.slice(last, index) }) const [text, comment, str, num, word] = match let kind: TokenKind = 'plain' if (comment) kind = 'comment' else if (str) kind = 'string' else if (num) kind = 'number' else if (word) { if (KEYWORDS.has(word)) kind = 'keyword' else if (/^[A-Z]/.test(word)) kind = 'type' else if (line[index + word.length] === '(') kind = 'fn' } tokens.push({ kind, text }) last = index + text.length } if (last < line.length) tokens.push({ kind: 'plain', text: line.slice(last) }) return tokens })}
/** * Typography for long-form content, written as plain Tailwind utility strings * instead of the typography plugin. Tweak a line here and every paragraph, * heading and list in the article follows. */export const prose = { lead: 'text-xl leading-relaxed text-gray-700 dark:text-gray-300', p: 'mt-6 text-[17px] leading-8 text-gray-700 dark:text-gray-300', h2: 'mt-14 scroll-mt-24 text-2xl font-bold tracking-tight text-gray-900 sm:text-[28px] dark:text-white', h3: 'mt-10 text-lg font-semibold text-gray-900 dark:text-white', a: 'font-medium text-teal-700 underline decoration-teal-700/30 underline-offset-4 transition hover:decoration-teal-700 dark:text-teal-300 dark:decoration-teal-300/30 dark:hover:decoration-teal-300', strong: 'font-semibold text-gray-900 dark:text-white', code: 'rounded-md bg-gray-100 px-1.5 py-0.5 font-mono text-[0.85em] text-gray-900 dark:bg-white/10 dark:text-gray-100', ul: 'mt-6 space-y-3 pl-6 text-[17px] leading-8 text-gray-700 marker:text-teal-600 list-disc dark:text-gray-300 dark:marker:text-teal-400', ol: 'mt-6 space-y-3 pl-6 text-[17px] leading-8 text-gray-700 marker:font-semibold marker:text-gray-400 list-decimal dark:text-gray-300', blockquote: 'mt-8 border-l-4 border-teal-500 pl-6 text-xl leading-relaxed font-medium text-gray-900 italic dark:text-white', figcaption: 'mt-3 text-center text-sm text-gray-500 dark:text-gray-400',} as const
Vue 3 blog home template with a featured post, drawn cover art, category chips, live search, pagination and a newsletter signup. Tailwind CSS with dark mode.
Premium Vue 3 documentation template: searchable sidebar, content pages, scroll-aware table of contents, tabbed code blocks with copy, callouts and a mobile drawer.
A complete Vue 3 admin dashboard template with a collapsible sidebar, hand-drawn SVG charts, a sortable customers table and settings. Tailwind CSS, dark mode.