Tailwind CSS vs Bootstrap – Why Developers Are Ditching Bootstrap and Never Looking Back 🎨

The CSS Framework War Nobody Asked For (But Everyone Has Opinions About)

Let’s be real — picking a CSS framework feels like choosing your starter Pokémon. You commit, you invest time, and then someone on Twitter tells you that you made the wrong choice.

For years, Bootstrap was the framework. You wanted a navbar? Bootstrap. A modal? Bootstrap. A button that looked like it belonged on a government website from 2013? Still Bootstrap. It worked, and it worked reliably. Nobody got fired for using Bootstrap.

But then Tailwind CSS showed up, and the frontend world quietly started having a crisis.

Suddenly, developers were writing className="flex items-center justify-between px-6 py-3 bg-orange-500 text-white rounded-lg" and enjoying it. No more custom CSS files. No more fighting specificity wars. No more “why does this div have margin I didn’t ask for?!”

So the real question is — is Tailwind actually better than Bootstrap, or is it just new and shiny? Buckle up, because we’re about to break this down like a senior dev explaining it over chai at the office. ☕

What Are Tailwind CSS and Bootstrap, Anyway?

Before we start throwing punches, let’s make sure we’re on the same page.

Bootstrap is a component-based CSS framework. Think of it like IKEA furniture — you get pre-built pieces (buttons, cards, navbars, modals), and you just assemble them. It ships with ready-made components, a 12-column grid system, and a bunch of JavaScript utilities baked in. Super convenient, especially when you want something up and running fast.

Tailwind CSS, on the other hand, is a utility-first CSS framework. Instead of giving you pre-built components, it gives you tiny, single-purpose CSS classes — utilities — that you combine directly in your HTML/JSX to build whatever you need. It’s like getting a full LEGO set instead of a pre-assembled kit. More effort upfront, but the end result is entirely yours.

Here’s a quick analogy: Bootstrap is ordering a pre-made burger from a fast food chain. Tailwind is getting all the fresh ingredients and building your perfect custom burger. One is faster; the other is exactly what you wanted.

Why This Topic Matters for Developers in 2025

Here’s something the tutorials don’t always tell you: your choice of CSS framework affects way more than just how your UI looks.

It affects your bundle size, your design flexibility, your team’s velocity, your codebase maintainability, and even how easy it is to hire developers who can hit the ground running on your project.

Bootstrap and Tailwind take fundamentally different philosophies toward styling, and understanding the tradeoffs is something every frontend developer needs in their toolkit — whether you’re a freelancer building client sites, a solo developer working on a SaaS product, or part of a large engineering team shipping a design system.

In 2025, most modern React and Next.js projects default to Tailwind. But that doesn’t mean Bootstrap is dead. It means you need to know why one fits better in certain scenarios — and that’s exactly what this post covers.

Benefits of Tailwind CSS with Real-Life Examples

Here’s where Tailwind starts pulling ahead. Let’s go through the actual wins:

1. 🔥 No Context Switching Between Files

With Bootstrap, you’d write your HTML, then open a separate CSS file to override the styles, then come back to HTML… it’s a ping-pong match that gets exhausting fast. With Tailwind, your styles live right next to your markup. You write, you see, you move on.

2. 📦 Tiny Production Bundle (Thanks to PurgeCSS / JIT)

Bootstrap’s CSS is heavy out of the box — around 180–200KB minified. Tailwind, with its JIT (Just-In-Time) compiler, only includes the classes you actually use. Most production Tailwind builds end up under 10KB. That’s a massive performance win for your users on slower connections.

3. 🎨 Total Design Freedom Without a Design System Fight

Ever tried to make a Bootstrap button look different from a Bootstrap button? You either override styles (specificity nightmare), modify the SCSS variables (setup overhead), or give up and just use the default blue button for the 500th time.

With Tailwind, you design what you want from scratch. Every color, spacing, border-radius — you control it using utility classes. No fighting the framework’s opinions.

4. ⚡ Faster Prototyping Once You Know the Classes

There’s a learning curve, sure. But once you internalize the class naming pattern — and it is very logical — building UIs becomes almost as fast as writing plain English. p-4 means padding: 1rem. text-xl means a larger font size. It clicks, and then it really clicks.

5. 🧩 Works Beautifully with Component-Based Frameworks

Tailwind was basically born for React, Next.js, and Vue. Since you’re already thinking in components, scoping styles to a component using Tailwind utilities feels completely natural. No CSS Modules setup, no Styled Components overhead — just classes in your JSX.

6. 🛠️ Highly Customizable Config

Tailwind’s tailwind.config.js lets you define your design tokens — your brand colors, fonts, spacing scale, breakpoints — all in one place. Consistent design system baked right in. It’s as easy as unlocking your phone once you know the password.

Tailwind CSS vs Bootstrap: A Straight-Up Comparison

Let’s put these two side by side. No fluff, just facts.

Feature Tailwind CSS Bootstrap
Approach Utility-first Component-based
Bundle Size (prod) ~5–15KB (JIT) ~180–200KB
Design Flexibility Very High Moderate
Learning Curve Moderate Low
Pre-built Components No (use Headless UI, shadcn/ui) Yes (many built-in)
Customization Excellent via config Requires SCSS override
JS Dependency None (pure CSS) jQuery / Popper (v4 drops jQuery)
Best For Custom UI, modern apps Rapid prototyping, legacy projects
Community & Ecosystem Growing fast Massive, established
React / Next.js fit ⭐⭐⭐⭐⭐ ⭐⭐⭐

The Honest Verdict

Bootstrap wins on raw speed for building generic interfaces. If you’re spinning up an admin dashboard with no custom design requirements, Bootstrap gets you there without thinking twice.

Tailwind wins on virtually everything else — performance, flexibility, maintainability, and modern developer experience. It’s the reason almost every new React/Next.js project you’ll see in 2025 is Tailwind-first.

Best Tips for Using Tailwind CSS 🧠

Do’s ✅

  • Do set up your tailwind.config.js with your brand colors and fonts from day one. Doing it later is a refactor headache.
  • Do use the @apply directive sparingly for repeated utility combinations (like a button style). But don’t overuse it — that defeats the purpose.
  • Do use a Tailwind IntelliSense extension in VS Code. It’s a game changer — autocomplete for every class.
  • Do learn the responsive prefixes early: sm:, md:, lg:, xl:. Mobile-first styling becomes dead simple.
  • Do pair Tailwind with a headless component library like shadcn/ui or Radix UI if you need accessible, unstyled components to dress up.

Don’ts ❌

  • Don’t write a thousand classes inline and call it a day. Extract repeated UI patterns into reusable React components.
  • Don’t mix Tailwind utilities with global CSS haphazardly. It gets messy quickly and causes unexpected overrides.
  • Don’t skip the JIT mode setup — you want that tiny bundle in production.
  • Don’t expect Tailwind to do everything Bootstrap does. It’s a different tool. Embrace that.

Common Mistakes Developers Make When Switching to Tailwind

If you’re coming from Bootstrap, here’s what trips people up — and what to watch out for.

1. Trying to recreate Bootstrap components in Tailwind manually
This is the wrong mindset. Tailwind isn’t Bootstrap with a different syntax. It’s a different philosophy. Stop looking for the Bootstrap equivalent and start thinking in utility-first terms.

2. Not configuring the theme upfront
Many developers start using Tailwind with default colors and spacing, then later realize their brand colors aren’t in the config. Set it up before writing a single component.

3. Overloading JSX with utility classes and not extracting components
Yes, className="flex items-center gap-4 px-6 py-3 bg-[#FF6F49] text-white font-semibold rounded-xl hover:bg-orange-500 transition-all duration-200" is valid Tailwind. But if you write that on every button, you’ve just created maintenance debt. Put it in a <Button /> component.

4. Ignoring the Tailwind docs
The Tailwind documentation is genuinely one of the best in the industry. Every class is documented with examples and live previews. There’s no excuse for guessing.

5. Assuming Tailwind is only for React
You can absolutely use Tailwind with plain HTML, Vue, Astro, SvelteKit, or any other modern web tech. It’s not framework-locked.

Conclusion: Should You Make the Switch? 🚀

Here’s the truth: Tailwind CSS isn’t just better than Bootstrap in a few areas — it’s a fundamentally more modern approach to styling that aligns with how we build UIs today. Component-based architecture, design systems, performance-first development — Tailwind fits right in.

Bootstrap is not dead. It still has a place in projects that need quick, opinionated scaffolding without a design team. But for anyone building production-grade React or Next.js apps with custom designs, Tailwind is the clear choice in 2025.

So — have you already made the switch to Tailwind, or are you still holding onto your Bootstrap container divs? Drop your thoughts in the comments. 👇

If you found this useful, you’ll love the other deep-dives on hamidrazadev.com — from Next.js performance tips to real-world frontend patterns. Go explore, bookmark what you love, and share this post with a developer friend who’s still on the fence about Tailwind. They’ll thank you later. 😄

🦀 Rust Weekly Log — Weekly progress snapshot

📡 RustPulse project
Explored Postgres persistence using sqlx and documented the workflow for schema initialization, ingestion pipeline, and verification strategy.
https://vinecksie.super.site/rustpulse

🔐 Sealed in Rust Crypto book
Started the Identity Systems domain chapter, covering real-world password storage with Argon2 and how cryptographic primitives compose into practical authentication architectures.
https://vinecksie.github.io/sealed-in-rust/03-domains/03-00-identity-systems.html

▶️ Fearless in Rust
Published a new video on ChaCha20: keystream structure, nonce safety considerations, and implementation details in Rust.
https://youtu.be/UUQj-9SiKKg

rust #rustlang #backend #cryptography #security #postgres #learninginpublic

How I Manage 15+ Repos with Claude Code (Without Losing My Mind)

Most Claude Code users work in one repo at a time. It’s fine until your system spans multiple repos — then you’re copy-pasting context between sessions, manually tracking which PR depends on which, and babysitting agents that can’t see the full picture.

I manage 15+ repos across Go, Rust, TypeScript, Python, and C++. 10 specialized Claude Code agents coordinate through Telegram. Here’s what I tried first, why it didn’t work, and what does.

What Doesn’t Work

All of these approaches try to solve “how do I give one session access to multiple repos.” But that’s the wrong framing. When you need cross-repo context, what you actually need is cross-repo read and explore. The write should always be focused on a single repo, a single PR. ttal handles this by separating the two: ttal ask reads and explores anything, workers write to one repo at a time.

Monorepo. I use Moon for monorepo management — I’m not anti-monorepo. But when your stack spans Go, Rust, TypeScript, Python, and C++, a single repo doesn’t cut it. Each language has its own build tooling, CI pipelines, and dependency management. Cramming them together creates more problems than it solves. Even with Moon handling the orchestration, past 3-4 languages, splitting is cleaner. This isn’t a theoretical concern — it’s a real gap in the CC ecosystem that people are hitting.

Submodules. Even if you link repos together with submodules, you still don’t get what you actually need: cross-repo coordination, shared context, parallel execution. Submodules give you a way to pin repo versions together — they don’t give you a way to plan across repos, route tasks, or run parallel agents. And they don’t compose well with worktrees, which are essential for parallel agent work. It’s solving the wrong problem.

CC’s native cross-repo workflow. Want to explore another repo? You manually /add-dir, then that session starts reading in that directory. It works, but it’s manual and the session context pays the cost. In ttal, all exploration is handled by ttal ask — a lightweight bash-only agent that collects info from any source (--web, --repo, --project) and returns a detailed report without polluting your main session’s context. And because ttal ask runs on a simple bash-based agent loop, you can use any fast, cheap model (MiniMax M2.7 HighSpeed) for exploration — so Opus stays focused on the thinking work that actually needs it.

Single-session cross-repo orchestration. This is the trap most people fall into. You give one CC session access to all your repos and ask it to plan and implement a cross-repo feature. The context window fills up fast, the agent loses track of which repo it’s in, and the quality of both planning and execution suffers. Don’t try to make one session do orchestration, planning, and execution across repos. Let manager agents hold the big picture. Once the plan is done, let workers handle execution detail — one repo, one task, one worktree.

What Works: A Coordination Layer

The answer wasn’t a better monorepo or a smarter IDE. It was a thin coordination layer on top of Claude Code.

ttal is a single binary. Install it, define your projects in a TOML file, and you have a system that routes tasks to the right repo, the right agent, at the right time.

Two planes:

Worker plane — ephemeral CC sessions that plan, review, and implement. Each gets its own git worktree, sandboxed environment, and tmux session. Spin up, do the work, merge, clean up. No babysitting.

Manager plane — persistent agents that live across sessions. They hold the big picture — what features they designed with you, which tasks are done or blocked, what shipped yesterday. The manager never touches code. The worker never worries about the big picture.

Message bridge — the glue between everything. Human ↔ agent via Telegram. Agent ↔ agent via ttal send. Manager ↔ worker via ttal alert (workers notify their spawner automatically). CI status, PR reviews, task updates — all routed through the same daemon. You talk to your agents like coworkers in a chat app.

I wrote about the philosophy in ttal — More Than a Harness Engineering Framework, the tooling in We Replaced Every Tool Claude Code Ships With, and the memory model in How We Manage Memory and Sessions.

Daily Workflow

I open Telegram. 10 agent chats in a folder.

A typical morning:

  1. Tell Yuki (orchestrator) what I want to build today
  2. She breaks it into tasks, routes them to the right pipeline
  3. ttal go advances each task — spawning planners, reviewers, coders in parallel
  4. Workers run in isolated worktrees across whichever repos need changes
  5. PR reviews happen automatically — parallel sub-reviewers check security, tests, types, edge cases separately
  6. I review verdicts, approve, ttal go merges and cleans up

Cross-repo features just work. A change that touches ttal-cli, temenos, and organon gets three parallel workers, each in their own worktree, each with context about why the change exists.

Under the Hood

  • Unix philosophy — task management via Taskwarrior, knowledge via FlickNote, editing via tree-sitter. Compose dedicated tools, don’t bundle into a platform.
  • Sandbox auto-config — specialized roles mean known paths. The multi-repo project registry means sandbox config writes itself. No manual permission prompts.
  • Pipeline-driven — tag-based pipelines borrowed from event sourcing. One command (ttal go) drives every transition. Human gates where they matter.
  • Built-in quality control — parallel sub-reviewers focus on different aspects (security, tests, silent failures, type design). By the time a PR reaches you, it’s been through plan review, code review, and CI.
  • Session forking — brainstorm figures out what and why, then the session forks. Each fork inherits the full conversation and writes a plan scoped to its target repo. No summarization, no lossy handoff. Plan forks figure out the how, workers carry all of it into implementation.

The Numbers

Last week: 190+ tasks completed across all repos. In ttal, each task is a PR — planned, reviewed, implemented, merged. One person, 10 agents.

Throughput scales because coordination is automated. I don’t track which session is doing what. I track tasks.

Getting Started

brew tap tta-lab/ttal
brew install ttal
ttal doctor --fix

Define your projects:

# ~/.config/ttal/projects.toml
[backend]
name = "Backend API"
path = "/code/backend"

[frontend]
name = "Frontend App"
path = "/code/frontend"

[infra]
name = "Infrastructure"
path = "/code/infra"

Route a task:

ttal task add --project backend "add rate limiting middleware" --tag feature && ttal go

The pipeline figures out the rest.

Multi-repo at scale with Claude Code isn’t about getting CC to understand all your repos at once. It’s about a coordination layer that routes work to the right repo, the right agent, at the right time.

ttal, organon, and temenos are open source at github.com/tta-lab.

How to Visualize Multiple Overlapping Routes on a MapLibre GL Map

Screenshot showing four routes from Paris landmarks to Arc de Triomphe, each route visually separated

When building route planners, logistics dashboards, or public transport maps, you often need to display multiple routes that share the same roads. Without proper handling, these routes stack on top of each other and only the last-drawn route is visible. Users cannot compare routes or even confirm that all paths are displayed.

In a previous tutorial, we explored three approaches to solve this problem in Leaflet. MapLibre GL offers a simpler solution: the built-in line-offset paint property. No plugins, no geometry manipulation – just a single property that visually shifts each route by a specified number of pixels.

This tutorial shows how to use line-offset to display multiple overlapping routes clearly on a MapLibre GL map.

Try the live demo:
View on CodePen

APIs used:

What you will learn:

  • When overlapping routes are a problem and how to solve it
  • How to use MapLibre GL’s line-offset property
  • Understanding the line layer paint parameters
  • Creating custom markers with the Map Marker Icon API

Table of Contents

  1. When Overlapping Routes Matter
  2. The Solution: line-offset Property
  3. Set Up Routes and Offsets
  4. Add Route Layers with Offset
  5. Understanding the Paint Parameters
  6. Add Custom Markers
  7. Explore the Demo
  8. Summary
  9. FAQ

When Overlapping Routes Matter

Multiple routes sharing the same roads is common in several scenarios:

Route Planners
When comparing alternative routes from A to B, the paths often overlap near the origin and destination. Users need to see all options to make informed decisions about travel time, distance, or toll roads.

Delivery and Logistics
Multiple drivers heading to the same warehouse or distribution center converge on the same streets. Dispatchers need to see all routes simultaneously to coordinate timing and identify bottlenecks.

Public Transport Maps
Bus or train lines frequently share corridors. A transit map that only shows one line per street fails to communicate the actual service coverage.

Multi-stop Route Comparison
When planning trips with multiple waypoints, users might want to compare different orderings. The routes will share many segments but differ in key sections.

In all these cases, stacked routes create confusion. The map appears to show fewer routes than expected, and users cannot visually compare the paths.

Screenshot showing four routes from Paris landmarks to Arc de Triomphe, each route visually separated

Four routes from different Paris landmarks to the Arc de Triomphe. Each route is visible even where they share the same streets.

The Solution: line-offset Property

MapLibre GL provides a built-in solution: the line-offset paint property. This property shifts the rendered line perpendicular to its direction by a specified number of pixels.

paint: {
    "line-color": "#E53935",
    "line-width": 4,
    "line-offset": -6  // Shift 6 pixels to the left
}

Key characteristics of line-offset:

  • Pixel-based: The offset is in screen pixels, not meters. Routes maintain consistent visual separation at all zoom levels.
  • Perpendicular shift: The line moves sideways relative to its direction, creating parallel paths.
  • No geometry modification: The underlying coordinates remain unchanged. Only the visual rendering is affected.
  • Positive/negative values: Positive values shift right (relative to line direction), negative values shift left.

For four routes, we use offsets like [-6, -2, 2, 6] to spread them evenly across a 12-pixel width.

This is much simpler than the Leaflet approaches we covered previously. In Leaflet, you need external libraries like Turf.js for geometric offset or plugins like leaflet-polylineoffset. MapLibre GL handles it natively.

Set Up Routes and Offsets

Define your routes with different colors and assign pixel offsets to each:

const DESTINATION = {
    lat: 48.8738,
    lon: 2.2950,
    name: "Arc de Triomphe"
};

const ROUTES = [
    {id: "r1", name: "From Eiffel Tower", color: "#E53935", origin: {lat: 48.8584, lon: 2.2945}},
    {id: "r2", name: "From Louvre", color: "#43A047", origin: {lat: 48.8606, lon: 2.3376}},
    {id: "r3", name: "From Notre-Dame", color: "#1E88E5", origin: {lat: 48.8530, lon: 2.3499}},
    {id: "r4", name: "From Sacré-Cœur", color: "#FB8C00", origin: {lat: 48.8867, lon: 2.3431}}
];

// Offset in pixels for parallel routes (visual offset only)
const OFFSETS = [-6, -2, 2, 6];

The offset values spread routes symmetrically: two routes shift left (-6, -2 pixels) and two shift right (2, 6 pixels). This keeps the routes centered on the actual road.

API Key: Sign up at geoapify.com to get your API key.

Add Route Layers with Offset

Each route needs two layers: an outline (darker, wider) and the main line (route color, narrower). The outline creates visual depth and helps routes stand out from the map.

function addRouteLayer(routeId) {
    const route = routeState[routeId];
    const offsetPixels = OFFSETS[route.index % OFFSETS.length];

    // Add source with original route geometry (no geometric offsetting)
    map.addSource(routeId, {
        type: "geojson",
        data: route.feature
    });

    // Add outline layer with pixel-based visual offset
    map.addLayer({
        id: `${routeId}-outline`,
        type: "line",
        source: routeId,
        layout: {
            "line-join": "round",
            "line-cap": "round"
        },
        paint: {
            "line-color": darkenColor(route.color, 30),
            "line-width": 7,
            "line-opacity": 0.9,
            "line-offset": offsetPixels
        }
    });

    // Add main line layer with same pixel-based visual offset
    map.addLayer({
        id: `${routeId}-line`,
        type: "line",
        source: routeId,
        layout: {
            "line-join": "round",
            "line-cap": "round"
        },
        paint: {
            "line-color": route.color,
            "line-width": 4,
            "line-opacity": 1,
            "line-offset": offsetPixels
        }
    });
}

Both layers use the same line-offset value so the outline and main line move together. The outline is 7 pixels wide, the main line is 4 pixels, creating a 1.5-pixel border on each side.

Understanding the Paint Parameters

The line layer paint properties control how routes appear on the map. Here’s what each parameter does:

line-offset

"line-offset": offsetPixels  // e.g., -6, -2, 2, or 6

The core parameter for this tutorial. It shifts the line perpendicular to its direction:

  • Negative values shift the line to the left (relative to the line direction)
  • Positive values shift the line to the right
  • Zero draws the line on the actual coordinates

For multiple routes, distribute offsets evenly. With 4 routes using [-6, -2, 2, 6], routes are spaced 4 pixels apart, creating a total width of 12 pixels.

line-width

"line-width": 7  // outline
"line-width": 4  // main line

The thickness of the line in pixels. The outline is wider than the main line to create a border effect. The difference (7 – 4 = 3 pixels) is split between both sides, giving a 1.5-pixel border.

line-color

"line-color": darkenColor(route.color, 30)  // outline - 30% darker
"line-color": route.color                    // main line - original color

The outline uses a darkened version of the route color. This creates depth and helps distinguish overlapping routes. The darkenColor helper reduces RGB values by a percentage.

line-opacity

"line-opacity": 0.9  // outline - slightly transparent
"line-opacity": 1    // main line - fully opaque

Opacity ranges from 0 (invisible) to 1 (fully opaque). The outline is slightly transparent (0.9) to soften the border effect.

Layout properties: line-join and line-cap

layout: {
    "line-join": "round",
    "line-cap": "round"
}
  • line-join: How line segments connect at corners. "round" creates smooth curves at turns. Other options: "miter" (sharp corners), "bevel" (flat corners).
  • line-cap: How line ends are drawn. "round" adds a semicircle at each end. Other options: "butt" (flat end), "square" (flat end extended by half the line width).

Using "round" for both creates smooth, professional-looking routes.

Close-up showing how the line-offset property creates parallel routes on a shared road segment

The line-offset property shifts each route perpendicular to its direction, creating clear visual separation.

Add Custom Markers

Use the Geoapify Map Marker Icon API to create markers for origins and the destination. Each origin marker matches its route color, making it easy to identify which path starts where.

function createMarker(lon, lat, color, iconName, name, label) {
    const el = document.createElement("div");
    el.style.width = "36px";
    el.style.height = "48px";
    el.style.backgroundImage = `url(https://api.geoapify.com/v2/icon?type=awesome&color=${color}&icon=${iconName}&iconType=awesome&size=48&scaleFactor=2&apiKey=${API_KEY})`;
    el.style.backgroundSize = "contain";
    el.style.backgroundRepeat = "no-repeat";
    el.style.cursor = "pointer";

    const popup = new maplibregl.Popup({offset: [0, -48]})
        .setHTML(`<strong>${name}</strong><br>${label}`);

    const marker = new maplibregl.Marker({element: el, anchor: "bottom"})
        .setLngLat([lon, lat])
        .setPopup(popup)
        .addTo(map);

    return marker;
}

Map Marker Icon API Parameters

The marker URL includes several parameters to customize the icon. See the full Marker Icon API documentation for all options.

Parameter Value Description
type awesome Marker style. Options: material (Material Design shape), awesome (Font Awesome marker shape), circle, plain (no background)
color %23E53935 Background color of the marker in hex (URL-encoded # as %23)
icon flag, circle Name of the inner icon. Depending on iconType, use Material Design icon names or Font Awesome v6 icon names
iconType awesome Icon library: material or awesome. Default depends on marker type
size 48 Icon image height in pixels
scaleFactor 2 Multiplier for retina displays (2x resolution)

The destination uses a flag icon, while origins use circle icons with colors matching their routes.

MapLibre GL Marker Options

new maplibregl.Marker({element: el, anchor: "bottom"})
  • element: Custom HTML element to use as the marker (instead of default pin)
  • anchor: Which part of the element sits on the coordinates. "bottom" places the pin tip on the location

The popup offset [0, -48] positions it above the marker (negative Y moves up).

Explore the Demo

The interactive demo shows four routes from Paris landmarks to the Arc de Triomphe. You can:

  • Toggle routes using checkboxes to show/hide individual paths
  • Click a route in the list to zoom and see details
  • Compare visually how routes overlap and diverge

Notice how the line-offset property keeps all routes visible even on shared road segments. Each color remains distinct without any complex geometry manipulation.

Summary

MapLibre GL’s line-offset property provides a simple, built-in solution for displaying multiple overlapping routes. Unlike Leaflet, which requires external libraries or plugins, MapLibre GL handles parallel route visualization natively.

Key points:

  1. line-offset is pixel-based – Routes maintain consistent visual separation at all zoom levels
  2. No geometry modification – The underlying coordinates stay unchanged; only rendering is affected
  3. Simple implementation – Just add "line-offset": pixelValue to your paint properties
  4. Works with outlines – Apply the same offset to both outline and main line layers

Recommended offset values:

  • For 2-3 routes: [-4, 0, 4] or [-3, 3]
  • For 4 routes: [-6, -2, 2, 6]
  • For more routes: Spread evenly, keeping total width under 20 pixels

When to use this approach:

  • Comparing alternative routes between the same points
  • Displaying multiple delivery or logistics routes
  • Building public transport maps with shared corridors
  • Any scenario where users need to see all routes simultaneously

Useful links:

FAQ

Q: How does line-offset compare to Turf.js lineOffset?
A: MapLibre GL’s line-offset is a visual/pixel-based offset applied during rendering. Turf.js lineOffset modifies the actual geometry in meters. The MapLibre approach is simpler and maintains consistent screen-space separation at all zoom levels. Turf.js creates truly parallel paths in geographic space, which may be better for precise geographic analysis.

Q: What happens at sharp turns or intersections?
A: The line-offset property handles corners reasonably well due to the line-join: round setting. At very sharp turns, you might see slight visual artifacts, but for typical road networks this is rarely noticeable.

Q: Can I change the offset dynamically?
A: Yes. Use map.setPaintProperty(layerId, 'line-offset', newValue) to update the offset without removing and re-adding layers. This is useful for interactive applications where users might adjust the separation.

Q: How many routes can I display before performance suffers?
A: MapLibre GL uses GPU rendering and handles dozens of routes efficiently. The line-offset calculation is done on the GPU, so it adds minimal overhead. For hundreds of routes, consider server-side simplification or clustering.

Q: Can I use different offsets at different zoom levels?
A: Yes. MapLibre GL expressions support zoom-based interpolation. For example:

"line-offset": [
    "interpolate", ["linear"], ["zoom"],
    10, 2,  // 2 pixels at zoom 10
    16, 8   // 8 pixels at zoom 16
]

Q: What marker icon types are available?
A: The Map Marker Icon API supports four types: material (Material Design shape), awesome (Font Awesome marker shape), circle (simple circle), and plain (no background shape). Each can include an icon from Font Awesome v6 or Material Design Icons libraries.

Try It Now

Open the Live Demo

Sign up at geoapify.com to get your API key and start building multi-route visualizations with MapLibre GL.

Your 2026 Mobile Stack Is Modern Everywhere Except Testing

I spent 6 months talking to mobile engineers about their tooling. Flutter or React Native on the frontend. Supabase or Firebase on the backend. GitHub Actions for CI/CD. Mixpanel for analytics. Sentry for crash reporting.

Every layer modern, maintained, actually pleasant to work with.
Then I’d ask about testing. The energy would shift.
Appium suites held together by brittle XPaths and Thread.sleep(). Espresso on Android, XCUITest on iOS same user flow, written and maintained twice. Flakiness rates sitting at 15-20%, sometimes spiking to 25% on real devices. One mobile lead estimated $200K/year in engineering time just on test maintenance not catching bugs, but fixing selectors that broke because someone changed an accessibility label or moved a component one level deeper in the hierarchy.

Some teams just stopped writing tests altogether. Fell back to manual QA for critical flows. Not because they wanted to because the testing experience was so painful that false failures every morning felt worse than no automation at all.

The numbers tell the same story. I audited the modern mobile stack across 8 layers using adoption data from Stack Overflow’s 2025 Developer Survey, Statista, and 40+ engineer conversations.

Here’s what stood out:

  • Flutter (46% market share) and React Native (35%) dominate frontend both shipped or had major architecture updates between 2017-2024.
  • Supabase hit $2B valuation and 1.7M+ developers. 40% of recent YC batches build on it.
  • GitHub Actions leads CI/CD for most teams. Bitrise reports 28% faster builds vs. GitHub Hosted Runners for mobile-specific workflows.
  • Sentry’s AI-powered root cause analysis hits 94.5% accuracy. Crashlytics remains free and solid.

All of this is 2019-2024 era tooling. Then there’s testing still running on frameworks built in 2011-2012. Appium was created the same year Instagram launched. Think about that for a second.

The core problem isn’t that Appium doesn’t work. It’s architectural. Selector-based testing couples your tests to implementation details. Your test doesn’t say “tap the login button” it says “find the element at //android.widget.Button[@resource-id=’com.app:id/login_btn’] and click it.”
Designer renames that ID? Test breaks. A promo banner shifts the layout? Timing error.
Need the same test on iOS? Rewrite it.

None of these failures mean your app is broken. They mean your
locator stopped matching. That’s busywork, not QA.

The architectural shift that’s closing this gap is Vision AI testing. Instead of querying the element tree, it looks at the rendered screen the same pixels your user sees. Tools like Drizz identify a “Login” button visually whether the underlying component is a Button, a TouchableOpacity, or a custom View with an onPress handler.
What that looks like in practice: a checkout flow that takes 30+ lines of Java with explicit waits and XPath selectors in Appium becomes 6 lines of plain English. Same coverage. Runs on both platforms without rewriting. And when the UI changes button moves, text updates, component gets refactored the test keeps passing because it’s not tied to the DOM.

The early numbers from teams running this approach: <5% flakiness vs. the 15-20% industry average. Test creation dropping from hours to minutes. And the part that surprised me most non-engineers (PMs, designers) actually contributing test cases because there’s no code to write.

I’m not saying rip out Appium tomorrow. If you’ve got a stable suite, deep device-level tests (biometrics, sensors, push notifications), or compliance requirements that mandate W3C WebDriver Appium is still the right tool. The full post gets into where each approach wins honestly.

But if you’re spending more sprint time fixing green-path tests than shipping features, the comparison is worth 10 minutes of your time.

👉 Read the full 8-layer stack audit with adoption stats, side by side code comparisons, and the ROI math on what test maintenance is actually costing your team

Your frontend is 2026. Your backend is 2026. Is your testing layer still stuck in 2012?

One File. No Server. How I Built an Image That Talks Back.

https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jcx0r3prv62kz4qia8zm.png

https://dev-to-uploads.s3.amazonaws.com/uploads/articles/jcx0r3prv62kz4qia8zm.png

Hello folks 👋

We’ve had a bunch of discussions about AI and computer vision, right? Still, today I’m bringing you something else, a little break. I am not revealing a gizmo I wielded but a thing I erected.

PhotoContour began with one riddle: what if a picture could chat with you?

We are talking about a single.svg that you open in any browser, move your mouse pointer over an photo object to see a popup with a label, a short text, and a link. No JavaScript. No dependencies. Just pure CSS.

This is an introduction to the secret behind it:
🔵 First YOLOv8 identifies all objects and extracts their exact contours.
🔵 Then FastAPI transforms these contour points into pixel locations and assembles the SVG.
🔵 React Studio facilitates you to select the object, add annotation, assign a color, and save.
🔵 The end product file is fully self-sufficient can be embedded in email, Twitter, LinkedIn, Discord, or any platform that can render SVG.

Speaking of bugs yes, there were bugs scattered. One was so cleverly disguised in a single variable name that it cost me a couple of hours figuring it out. The solution was one word.

I have done the entire build story the architecture”, decisions, the” coordinate system gotcha, the” “accuracy problem that was actually a UX problem”, and what’s coming next.

📖 Full post here 👇
https://vickkykruzprogramming.dev/blog/one-file-no-server-how-i-built-an-image-that-talks-back

If you have ever desired to animate your pictures, this one might really be a treat for you!

OpenSource #Python #FastAPI #ComputerVision #SVG #React #SideProject #WebDevelopment #YOLOv8 #BuildInPublic

Vite Environment Variables: Master .env & import.meta.env

Managing environment-specific configurations like API endpoints, feature flags, or secret keys is a fundamental challenge in frontend development. Hardcoding these values directly into your codebase is unsustainable, insecure, and error-prone when deploying to different environments. Vite, a modern and fast build tool, provides a streamlined and explicit way to handle environment variables, offering clarity and security for different deployment targets.

This guide dives into Vite’s specific mechanisms for managing environment variables, moving beyond a simple overview to practical application with code examples, focusing on how and why these approaches work. We’ll explore import.meta.env, .env files, and the concept of ‘modes’.

Core Concept: Vite’s Approach to Environment Variables

Vite’s approach to environment variables differs significantly from traditional Node.js-based setups. While Node.js applications often rely on process.env, Vite applications primarily use import.meta.env within client-side code. This distinction is crucial because Vite performs a static replacement during the build process for variables prefixed with VITE_, making them available in your bundled frontend code.

Variables without the VITE_ prefix are not exposed to the client bundle. They are only accessible within the Vite config file (vite.config.js) or during Node.js server-side operations (e.g., custom Vite plugins or SSR setups). This mechanism helps prevent accidental exposure of sensitive information.

Defining Environment Variables with .env Files

To manage variables, Vite leverages .env files, similar to the popular dotenv library. These files are loaded based on the mode your application is running in. The mode is a string that indicates the environment, typically ‘development’ (for vite or vite dev) or ‘production’ (for vite build).

Vite provides a hierarchy for .env files:

  • .env: General defaults.
  • .env.local: Local overrides, ignored by Git.
  • .env.[mode]: Mode-specific overrides (e.g., .env.development, .env.production).
  • .env.[mode].local: Local, mode-specific overrides, ignored by Git.

Let’s set up a common scenario with three files:

.env             # General defaults
.env.development # Overrides for development mode
.env.production  # Overrides for production mode

Here’s an example of their content:

./.env

VITE_APP_NAME="My Awesome App"
VITE_API_BASE_URL="https://api.example.com/v1"
SECRET_API_KEY="this-should-not-be-exposed" # Not prefixed, thus not exposed to client

./.env.development

VITE_API_BASE_URL="http://localhost:3001/api/v1"

./.env.production

VITE_API_BASE_URL="https://prod-api.example.com/v1"

Explanation: In these files, we define key-value pairs. Variables prefixed with VITE_ (e.g., VITE_APP_NAME, VITE_API_BASE_URL) will be exposed to your client-side code via import.meta.env. Variables without this prefix (like SECRET_API_KEY) are not exposed to the client, making them suitable for server-side configurations or within vite.config.js. Vite automatically loads .env files based on the current mode, with .env.[mode] taking precedence over the generic .env.

Accessing Variables in Your Application

Within your frontend JavaScript or TypeScript code, you access these environment variables through the special import.meta.env object. This object is populated by Vite during the build or development server startup.

// src/main.js or src/App.vue

// Accessing custom environment variables
console.log("App Name:", import.meta.env.VITE_APP_NAME);
console.log("API Base URL:", import.meta.env.VITE_API_BASE_URL);

// Vite also exposes built-in environment flags
if (import.meta.env.DEV) {
  console.log("Running in development mode");
} else if (import.meta.env.PROD) {
  console.log("Running in production mode");
}

// Accessing the current mode string
console.log("Current Mode:", import.meta.env.MODE);

// Attempting to access an unprefixed variable will result in 'undefined'
console.log("Secret API Key (should be undefined):", import.meta.env.SECRET_API_KEY);

const element = document.getElementById('app-info');
if (element) {
  element.textContent = `App: ${import.meta.env.VITE_APP_NAME}, API: ${import.meta.env.VITE_API_BASE_URL}`;
}

Explanation: import.meta.env is a special object that Vite populates with your environment variables. VITE_APP_NAME and VITE_API_BASE_URL are successfully logged, reflecting the values from the appropriate .env file for the active mode. Vite also provides useful boolean flags like DEV and PROD to conditionally execute code based on the current environment, eliminating the need to manually parse NODE_ENV. Note that import.meta.env.SECRET_API_KEY correctly returns undefined, preventing accidental exposure of sensitive data to the client, reinforcing the importance of the VITE_ prefix.

Running with Different Modes and Customizing Build Behavior

Vite commands inherently use a mode. When you run vite (or vite dev), it defaults to development mode. When you run vite build, it defaults to production mode. You can explicitly specify a mode using the --mode CLI flag.

This is particularly useful for environments like staging or test, which require their own distinct configurations without being ‘development’ or ‘production’.

Let’s add a build:staging script to package.json and a corresponding .env.staging file:

package.json

{
  "name": "vite-env-example",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "build:staging": "vite build --mode staging",
    "dev:staging": "vite --mode staging"
  }
}

Now, create the ./.env.staging file:

./.env.staging

VITE_API_BASE_URL="https://staging-api.example.com/v1"
VITE_APP_ENVIRONMENT="staging"

Explanation: The scripts section in package.json demonstrates how to use the --mode flag. npm run dev:staging will start the development server using staging mode, causing Vite to load variables from .env.staging (and then .env). Similarly, npm run build:staging will create a production build configured specifically for the staging environment. This explicit mode control allows for distinct configurations tailored to various deployment pipelines without altering your core application code.

Common Mistakes / Gotchas

  • Forgetting VITE_ prefix: This is the most common mistake. Only variables prefixed with VITE_ are exposed to your client-side code via import.meta.env. Variables like API_KEY=123 will not be accessible in your components. If you need to access non-prefixed variables, they are typically for vite.config.js or server-side scripts.
  • Using process.env: This is a common habit from Node.js development. In a Vite frontend application, process.env is not available at runtime in the browser. Always use import.meta.env for client-side variables.
  • Exposing Sensitive Information: Even with the VITE_ prefix, remember that any variable exposed via import.meta.env is eventually bundled into your client-side JavaScript. This means it’s publicly visible if someone inspects your deployed code. Never put truly sensitive information (like private API keys, database credentials) directly into .env files that will be used for client-side builds. Instead, use a backend proxy or server-side logic to handle such secrets securely.
  • Understanding mode vs. NODE_ENV: While import.meta.env.MODE reflects the current mode (e.g., ‘development’, ‘production’, ‘staging’), import.meta.env.DEV and import.meta.env.PROD are specific boolean flags Vite provides. NODE_ENV is not directly used by Vite for mode resolution but might be present as a fallback from other tools. Always rely on import.meta.env.MODE, import.meta.env.DEV, import.meta.env.PROD for consistency within Vite.

Key Takeaways

  • Vite uses import.meta.env for client-side environment variables, not process.env.
  • Variables must be prefixed with VITE_ to be exposed to the client bundle.
  • .env files (e.g., .env, .env.development, .env.production, .env.[mode]) allow for environment-specific configuration.
  • The --mode CLI flag explicitly controls which .env files Vite loads for development or build processes.
  • Never expose truly sensitive secrets directly to the client bundle; use a backend or server-side rendering for true privacy.

Conclusion

Effectively managing environment variables is crucial for building robust and adaptable frontend applications. Vite’s clear and explicit mechanism, leveraging import.meta.env and mode-specific .env files, simplifies this process significantly. By understanding these conventions and avoiding common pitfalls, you can ensure your application behaves predictably across development, staging, and production environments, enhancing both security and developer experience. Implement these practices today to streamline your configuration workflow and enhance your application’s deployment reliability.

I Built a Cloud-Native Workflow Engine for Kubernetes – What Would You Use It For?

Hey everyone, I’ve been building an open-source workflow engine that runs natively on Kubernetes called Tiny Systems (still working on the name). Not “deployed on Kubernetes” – actually native. Flows are CRDs. Nodes are CRDs. So entire state lives in etcd. No external database.

Think n8n or Node-RED, but designed for people who already run Kubernetes and want automation that feels like part of their cluster.

Problems I’m solving right now

These are ready-to-use solutions you can clone and deploy:

  • Cluster Cost Saver — scales down your deployments every evening, brings them back every morning. Dead simple, saves real money. No Lambda functions, no cronjobs, no external schedulers – just a flow inside your cluster.

  • Pod Failure Alerts — watches pods in real time, catches CrashLoopBackOff, OOMKilled, image pull errors, and sends a Slack message before your users start complaining. Yes, Alertmanager exists, but I get my slack message next second after the crash (not like 1-5min it takes for prometheus chain).

  • GitHub Deploy Bot — receives a webhook after CI builds an image, updates the Kubernetes deployment, posts the result to Slack. A lightweight GitOps pipeline without ArgoCD/FluxCD overhead for simpler setups. Using it for really tiny (the title!) clusters.

  • Firestore to ConfigMap Sync – listens to a Firestore collection and syncs changes to a Kubernetes ConfigMap in real time. Useful for feature flags. Got idea from app managements, thought why not to use that for K8s.

  • Service Status Page — a self-hosted status page that pings your endpoints and serves a page. No Statuspage.io subscription, no cron jobs, just a flow.

What workflow engine does today

You build flows visually – drag nodes, connect them, configure, deploy. Each component module runs as its own pod. Nodes in the same module talk over Go channels, across modules — gRPC. Everything stays inside your cluster.

There’s also an AI assistant that can build flows from a prompt, but that’s a story for another post.

Where I’m headed next

These are the solutions I’m planning to build:

  • CronJob failure alerter — Kubernetes CronJobs fail silently. This would watch them and alert on failure via Slack or PagerDuty.
  • TLS certificate expiry watcher — scan ingresses across namespaces, alert before certs expire (sometimes I miss emails from acme)
  • Slack command bot — slash commands that trigger Kubernetes actions: restart a pod, get logs, scale a deployment. ChatOps without writing a bot from
    scratch.
  • Webhook relay and transformer — receive webhooks from external services, reshape the payload, forward to internal services. A lightweight alternative to writing one-off HTTP handlers.

Why I’m writing this

I’ve been building in isolation for too long. The engine works. The solutions work. But I keep guessing what people actually need instead of asking.

So – if you run Kubernetes at work:

  • Which of the solutions above would you actually use?
  • What’s a recurring ops task you’ve been meaning to automate but haven’t?
  • What’s keeping you on n8n / Airflow / bash scripts?

Thanks!

From if Statements to Classes: How Refactoring Taught Me Testable Code

From Inline Code to OOP: What Building a Python Calculator Taught Me About Testability

I am a self-taught learner. So when I decided to build a Python calculator, I didn’t start with classes or design patterns. I started with the messiest, most beginner thing possible — everything crammed into one script. And honestly? That was the right decision.

This article is about how I evolved that calculator through three versions, and what each step taught me about writing code that’s actually testable — which matters a lot when you’re learning QA automation like I am.

👉 GitHub: https://github.com/enayetrashid/python-calculator-evolution

Version 1: The “Just Make It Work” Phase

My first version was pure chaos by design. No functions, no classes; just raw logic from top to bottom.

a = float(input("Enter first number: "))
b = float(input("Enter second number: "))
operation = input("Enter operation (+, -, *, /): ")

if operation == "+":
    print(a + b)
elif operation == "-":
    print(a - b)
elif operation == "*":
    print(a * b)
elif operation == "/":
    print(a / b)

It worked. But the moment I asked myself “how do I test this?” I had no answer. You can’t test code that demands user input just to run. This was my first real lesson:

If input and logic live together, testing becomes nearly impossible.

Version 2 — Introducing Functions

I pulled the calculation logic out into separate functions. A small change — but it changed everything.

def add(a, b):
    return a + b

def subtract(a, b):
    return a - b

Now I could write an actual test:

def test_add():
    assert add(2, 3) == 5

No input prompts. No manual interaction. Just pure logic I could verify instantly. This felt like a superpower.

The lesson: Functions are the foundation of testable code. If you can’t call it in isolation, you can’t test it properly.

Version 3 — Moving to OOP

Finally I wrapped everything in a class and added proper error handling:

class Calculator:
    def add(self, a, b):
        return a + b

    def divide(self, a, b):
        if b == 0:
            raise ZeroDivisionError("Division by zero")
        return a / b

The divide method was a turning point for me. Adding that guard clause meant I could now test failure scenarios too — not just the happy path.

def test_divide_by_zero():
    calc = Calculator()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)

The lesson: OOP isn’t just about organisation — it’s about making your code extensible and testable at scale.

Why This Matters Beyond the Calculator

I’m learning QA automation, and this little project taught me something that no tutorial explained clearly:

The structure of code directly affects how easy it is to test.

In real automation work with tools like Pytest or Playwright, you never test everything in one place. You separate test logic, business logic, and test data. This calculator evolved through exactly that same pattern:

Version Structure Testability
Version 1 Everything inline Nearly impossible to test
Version 2 Functions Testable in isolation
Version 3 OOP Scalable and extensible

Key Takeaways

  • Start simple — but don’t stay simple
  • Refactoring is a skill, not just a task
  • Testability should guide your design decisions from the beginning
  • Even the smallest projects can teach big engineering concepts

Final Thought

This wasn’t really about building a calculator. It was about learning to think like someone who cares about quality — not just getting something to run, but building it so it can be verified.

What’s the simplest project that taught you something fundamental? I’d love to hear it in the comments.

I’m a self-taught QA learner from Bangladesh documenting my journey into software testing and automation. If you’re on a similar path, feel free to connect!

Your framework choice is now your biggest AI cost lever

The Wasp team published something worth reading today — they gave
Claude Code the exact same feature prompt for two identical apps, one in Next.js and one in Wasp, and measured everything.

The numbers:

Metric Wasp Next.js
Total cost $2.87 $5.17
Total tokens 2.5M 4.0M
API calls 66 96
Output tokens (code written) 5,416 5,395

The last row is the interesting one. The AI wrote almost exactly
the same amount of code. But it cost 80% more to do it in Next.js.

The reason: cache creation and cache reads. Every LLM call re-reads the codebase context from scratch. A bigger codebase means every single turn costs more — not just for reading, but for loading into cache in the first place.

Next.js cache creation was 113% more expensive. Not because the AI did more. Because it had more boilerplate to read before it could start.

What this actually means

We’ve been evaluating frameworks on DX, performance, and ecosystem.
Add a new one: context efficiency.

How much of an AI’s context window goes to signal (your business logic) vs noise (framework boilerplate)?

Wasp’s declarative config means auth, routing, and jobs are defined in ~10 lines. Next.js equivalent is spread across middleware, route handlers, session files, and API directories. Same result, 4x the tokens.

The compounding problem

This test was a single feature. Real apps accumulate features. Every new route, every new model, every new API handler adds to the context that gets re-read on every single LLM call.

The performance degradation isn’t linear either — research shows that AI performance degrades well before the context window fills.
You’re not just paying more per call, you’re getting worse output.

What to do about it

  1. Measure your codebase token count now.
    Run: find . -name "*.ts" -o -name "*.tsx" | xargs wc -c
    That’s roughly your AI cost baseline.

  2. Audit your boilerplate ratio.
    How much of that is business logic vs glue code?
    The higher the glue ratio, the worse your AI economics.

  3. Consider framework choices through this lens.
    Wasp, Rails, Laravel — highly opinionated frameworks
    have a new advantage they didn’t have 2 years ago.
    Less boilerplate = cheaper AI = faster iteration.

The irony

The same properties that make a codebase easy for humans to navigate — explicit, verbose, self-documenting — are exactly what’s expensive for AI. The abstractions we used to see as “magic” are now genuinely economical.

I’ve been thinking about this for ToolDock — a browser-based
dev tools platform I’m building. Every tool page is pure business logic with almost no framework boilerplate, because everything runs statically. The AI token efficiency on it is noticeably better than client projects I’ve worked on with heavier stacks.

Worth checking the full Wasp post for the methodology details — they open-sourced both apps and the measurement scripts, which is the right way to publish a benchmark.

What’s your current codebase token count?