Building a Hash Generator with Web Crypto API and a Pure-JS MD5 Fallback

Every developer runs into hashing at some point. Checking file integrity, storing password fingerprints, verifying API payloads, generating cache keys.

Most reach for a CLI tool (shasum, md5sum) or an npm package. But what if you could hash text and files directly in the browser — no installs, no dependencies, no server?

I built a Hash Generator that supports MD5, SHA-1, SHA-256, SHA-384, and SHA-512. All client-side. Here’s how it works under the hood.

The Web Crypto API Does Most of the Work

Modern browsers ship with crypto.subtle — a native cryptographic API that handles SHA algorithms with hardware-accelerated performance.

async function computeHash(
  data: Uint8Array,
  algorithm: Algorithm
): Promise<string> {
  if (algorithm === "MD5") return md5(data);

  const hashBuffer = await crypto.subtle.digest(algorithm, data);
  return Array.from(new Uint8Array(hashBuffer))
    .map(b => b.toString(16).padStart(2, "0"))
    .join("");
}

That’s it for SHA-1, SHA-256, SHA-384, and SHA-512. crypto.subtle.digest() accepts the algorithm name and a BufferSource, returns an ArrayBuffer. Convert each byte to a two-character hex string, join them, done.

Three things worth noting:

  1. It’s async. digest() returns a Promise. For large files, this prevents UI freezing since the browser can offload computation.

  2. Algorithm names match directly. Web Crypto uses "SHA-256", "SHA-512", etc. — same strings we display in the UI.

  3. It’s fast. Native implementations are significantly faster than any JavaScript library. SHA-256 on a 10MB file takes milliseconds.

Why MD5 Needs a Pure-JS Implementation

Here’s the catch: Web Crypto doesn’t support MD5.

And it’s intentional. MD5 is cryptographically broken — collisions can be generated in seconds. The Web Crypto spec deliberately excludes it to discourage use in security contexts.

But MD5 is still useful. Checksums, legacy system compatibility, non-security fingerprinting. Developers need it.

So I wrote a ~50-line MD5 implementation directly in the component. No external library.

The MD5 Algorithm in JavaScript

MD5 processes data in 64-byte (512-bit) blocks. Each block goes through 64 rounds of bitwise operations across four auxiliary functions.

Step 1: Padding

The input must be padded to a multiple of 64 bytes, with the original bit length appended:

const len = input.length;
const bitLen = len * 8;
const padLen = ((56 - (len + 1) % 64) + 64) % 64;
const padded = new Uint8Array(len + 1 + padLen + 8);
padded.set(input);
padded[len] = 0x80; // Append 1-bit separator

// Little-endian 64-bit length
const view = new DataView(padded.buffer);
view.setUint32(padded.length - 8, bitLen >>> 0, true);
view.setUint32(padded.length - 4, Math.floor(bitLen / 0x100000000), true);

The 0x80 byte marks the end of the message. Then zeros fill until 56 bytes mod 64. Finally, the original message length (in bits) is written as a little-endian 64-bit integer.

Step 2: Block processing

Four state variables (a, b, c, d) are initialized to fixed values. Each 64-byte block modifies these through 64 rounds:

  • Rounds 0–15: F(b, c, d) = (b & c) | (~b & d)
  • Rounds 16–31: G(b, c, d) = (b & d) | (c & ~d)
  • Rounds 32–47: H(b, c, d) = b ^ c ^ d
  • Rounds 48–63: I(b, c, d) = c ^ (b | ~d)

Each round adds a constant from a precomputed table (k), adds a word from the block, applies a left rotation by a specified amount (s), and chains into the next round.

Step 3: Output

The final a, b, c, d values are concatenated as little-endian bytes and converted to hex:

[a, b, c, d].map(n =>
  [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]
    .map(b => b.toString(16).padStart(2, "0"))
    .join("")
).join("");

Note the little-endian extraction — MD5 uses little-endian byte order, unlike SHA which uses big-endian. Getting this wrong produces valid-looking but incorrect hashes.

Why Not Use a Library?

An npm MD5 package would work fine. But it adds a dependency for ~50 lines of deterministic code. The algorithm hasn’t changed since 1992. There’s no bug fix or security patch coming.

Inlining it:

  • Eliminates the dependency
  • Reduces bundle size (~3-5KB saved)
  • Keeps everything in one file for easier maintenance

For a utility like this, fewer dependencies is better.

Normalizing Input: Text vs. Files

A hash function operates on bytes, not strings. So both text and file inputs need to become Uint8Array before hashing.

Text input:

const data = new TextEncoder().encode(value);

TextEncoder converts a JavaScript string to UTF-8 bytes. This matters because the same string can produce different hashes under different encodings. UTF-8 is the standard.

File input:

const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);

File.arrayBuffer() reads the raw binary content. No encoding conversion — the bytes go directly to the hash function. This is critical for binary files (images, executables, archives).

By normalizing both paths to Uint8Array, the computeHash function doesn’t need to know where the data came from:

const generate = async (
  data: Uint8Array,
  algo: Algorithm,
  upper: boolean
) => {
  setIsProcessing(true);
  const hash = await computeHash(data, algo);
  setOutput(upper ? hash.toUpperCase() : hash);
  setIsProcessing(false);
};

Real-Time Hashing on Every Keystroke

Instead of a “Generate” button, the hash updates live as you type:

const handleTextChange = async (value: string) => {
  setInput(value);
  if (!value) { setOutput(""); return; }
  const data = new TextEncoder().encode(value);
  await generate(data, algorithm, uppercase);
};

This works because:

  1. Web Crypto’s digest() is fast enough for small inputs (sub-millisecond)
  2. The async pattern prevents blocking even if input gets large
  3. React’s state batching prevents unnecessary re-renders

Changing the algorithm also triggers recomputation with the existing input — so you can compare SHA-256 vs SHA-512 of the same text instantly.

Output Formatting

Two small but important details:

Hex padding:

b.toString(16).padStart(2, "0")

Without .padStart(2, "0"), a byte value of 0x0A would output "a" instead of "0a". This would produce hashes with inconsistent lengths — SHA-256 might be 63 characters instead of 64. Always pad.

Uppercase toggle:

Some tools output uppercase hex (A3B2C1), others lowercase (a3b2c1). Both are valid. The tool defaults to lowercase (more common) but offers a toggle. The hash is recomputed on toggle to maintain consistency.

SSR Pitfall: Web Crypto Is Browser-Only

crypto.subtle doesn’t exist on the server. In Next.js with SSR, this causes a build-time crash.

The fix:

const HashGenerator = dynamic(
  () => import("./HashGenerator").then((mod) => mod.HashGenerator),
  { ssr: false }
);

ssr: false ensures the component only renders in the browser where crypto.subtle is available. The page shell (SEO content, schema markup) still renders server-side.

Algorithm Comparison: When to Use What

Algorithm Output Speed Use Case
MD5 32 chars Fastest (JS) Checksums, cache keys, legacy
SHA-1 40 chars Fast Git commits (legacy), non-security
SHA-256 64 chars Fast File integrity, API signatures
SHA-384 96 chars Fast TLS, certificate chains
SHA-512 128 chars Fast Maximum security, password hashing input

For anything security-related, use SHA-256 or higher. MD5 and SHA-1 are fine for non-security purposes like deduplication or cache busting.

Try It

The live tool is at ultimatetools.io/tools/coding-tools/hash-generator/.

Paste text or drop a file. Switch between algorithms. Compare outputs. All processing stays in your browser.

If you’re building something similar, the key takeaways:

  1. Use crypto.subtle.digest() for SHA — it’s native, fast, and async
  2. Implement MD5 yourself if you need it — 50 lines, no dependency
  3. Normalize all input to Uint8Array before hashing
  4. Always padStart(2, "0") when converting bytes to hex
  5. Use ssr: false in Next.js — Web Crypto is browser-only

Built with Next.js, TypeScript, and zero external crypto libraries. Part of Ultimate Tools — a collection of free, privacy-first browser tools.

Microsoft’s Own VP Hates the Mandatory Account Requirement. The Teams That Profit From It Won’t Let Go.

Microsoft’s own VP just said “Ya I hate that. Working on it.” about the mandatory Microsoft Account requirement in Windows 11.

That’s Scott Hanselman. Vice President of Developer Community. The person Microsoft literally pays to make developers happy.

And he’s publicly admitting the setup experience for Windows 11 is broken.

Here’s the backstory

Windows 11 launched in October 2021. From day one, the Home edition forced you to create a Microsoft Account during setup. No internet? No local account? No computer.

By mid-2022, they extended the same requirement to Windows 11 Pro.

The developer community responded exactly how you’d expect. People started sharing workarounds like oobebypassnro in Command Prompt. Others used Rufus to create modified install media. Some disconnected their ethernet mid-setup just to get a local account.

Microsoft noticed. They started patching the workarounds. Killing bypass scripts. Removing the “I don’t have internet” button from preview builds.

It became an arms race between Microsoft and its own users.

Why this matters to devs

If you’ve ever set up a test VM, a CI runner, or a bare-metal build machine, you know this pain. You don’t want your throwaway dev environment tied to your personal Microsoft Account. You don’t want OneDrive syncing your temp files. You don’t want Copilot suggestions on a machine you’re wiping next week.

Enterprise customers get around this with domain joins and Intune. But individual developers, freelancers, and hobbyists? They’re stuck playing cat-and-mouse with setup hacks.

The real fight is internal

According to Windows Central, senior engineers inside Microsoft have been pushing to drop the requirement. Hanselman’s tweet wasn’t a solo act. Multiple employees reportedly want an “MSA-free” login option.

But here’s the friction. The teams that own advertising, product engagement metrics, and Microsoft 365 upsells benefit from every forced account creation. Every new setup is a data point, a potential subscription, a number on a quarterly slide.

It’s the classic platform tension. The people building the product want it to be good. The people measuring the product want it to convert.

The pattern is bigger than Windows

This isn’t just a Microsoft problem. It’s the same pattern everywhere.

→ Adobe won’t let you use Photoshop without Creative Cloud
→ JetBrains requires an account for IDE activation
→ Even VS Code’s Settings Sync nudges you toward GitHub or Microsoft login

The industry decided that accounts equal engagement and engagement equals revenue. Users became conversion metrics, not people trying to get work done.

What actually changed

Hanselman’s tweet landed on March 20, 2026. A week later, there’s no official announcement, no timeline, no committed plan.

The teams that benefit from mandatory accounts are reportedly resisting the change. So we have a Microsoft VP publicly saying “I hate that” about his own company’s product while other teams inside the same company fight to keep it.

If that doesn’t perfectly capture how large companies make product decisions, I don’t know what does.

The optimistic read is that enough internal pressure will eventually win. The realistic read is that engagement metrics rarely lose to developer experience in a boardroom.

What do you think? Should operating systems require an account to set up, or is that a line that should never have been crossed? 👇

Sprint 2 Preview: Five Decisions That Will Shape the Platform

Sprint 2 Preview: Five Decisions That Will Shape the Platform

The Journey So Far

This is the third post in a series documenting the ORCHESTRATE Marketing Platform build — an AI-agent-driven project where every line of code is written through Documentation-Driven Test-Driven Development (DD TDD), enforced by an MCP server that mechanically blocks methodology violations.

Sprint 0 was pure infrastructure. No features. No UI. Just the foundation: V2 migration bridge with SHA-256 audit trail, backup manager with integrity verification, credential rotation scheduler, Docker multi-service deployment, and SQLite databases with WAL mode. 18 tickets completed. 468 tests. The AI posted a blank coffee mug to LinkedIn, failed spectacularly, and we wrote about it honestly.

Sprint 1 built on that foundation with 17 tickets across 5 stories:

  • CI/CD & Test Infrastructure (OAS-090): Service lifecycle management, Docker Compose V3 validation, V2-to-V3 migration verification
  • Quality Gates & Standards (OAS-091): Dev.to skip guards for sensitive data, the Result<T,E> type for explicit error handling, startup validation
  • Infrastructure Health & Monitoring (OAS-092): Database manager, integration test framework, Health.tsx dashboard component
  • Publishing Reliability (OAS-076): Dev.to API verification, blog format compliance, publish-repair pipeline
  • Sprint 1 Retrospective (OAS-074): Work log, persona context review, turn-based ceremony with 11 AI personas, summary and decisions, blog post

Test count climbed from 468 to 925+. Zero blocked items across both sprints.

What the Platform Does Today

The ORCHESTRATE Marketing Platform V2 is a 102-tool MCP server managing content across 4 LinkedIn pages (ORCHESTRATE Method, Run On Rhythm, LEVEL UP, I am HITL). The full capability set:

  • LinkedIn: Post lifecycle, scheduling, analytics, engagement tracking, comment moderation, resharing, approval queues
  • Printify: Product creation, mockup generation, storefront publishing, order tracking
  • Reddit: Search, post, comment, vote across subreddits
  • Dev.to: Article CRUD, search, publishing pipeline with format validation
  • Replicate: AI image generation for visuals and product designs
  • KDP Sales: Amazon book sales import, royalty tracking, marketplace analytics
  • UTM/Attribution: Tracked short links, click recording, post-to-sale correlation
  • Content Calendar: AI-generated weekly plans following 40/20/20/20 content mix
  • A/B Testing: Split tests between post variants with auto-measurement
  • Audience Segmentation: Auto-tagging (Champions, Regulars, Engaged, New) based on interaction frequency
  • Competitor Monitoring: Cross-platform content discovery and relevance scoring
  • Performance Learning: Hook analysis, posting time optimization, hashtag effectiveness with confidence scores
  • Newsletter: Mailchimp-connected campaigns with auto-generated content

All of this runs in a single Docker container: Express API server, React UI, SQLite databases, MCP server, and a built-in scheduler. One docker compose up -d --build and the entire platform is running.

What Failed and What We Learned

The honest failures:

  1. Duplicated test fixtures. The same V2 test data (pages, posts, activity lines, API key patterns) is copy-pasted across 3+ test files. A change to fixture format means updating every copy. This is real technical debt that slows down every new test file.

  2. Inconsistent error handling. Some services use try-catch, others use the new Result<T,E> type. The boundary between the two patterns is implicit — you have to read the code to know which pattern a service uses. No documentation, no lint rule, no convention entry.

  3. No migration framework. Database schema changes are manual. There’s no version tracking, no forward-only migration chain, no way to know which schema version a given database is at. This blocks any multi-environment deployment.

  4. Startup validator is a black box. It runs checks and logs pass/fail, but the events aren’t structured. The health dashboard can’t consume them. There’s no auto-refresh, no way to see when the last check ran, no pause/resume.

  5. String-concatenated paths. Windows path separators broke tests that used 'data/' + filename instead of path.join('data', filename). We found and fixed instances in Sprint 1, but there’s no lint rule preventing new ones.

  6. The AI said “last week” when it meant “earlier today.” Our Performance Learning Memory engine was being built in the same session where the AI demonstrated exactly why it was needed. We wrote about that too.

The lessons that stuck:

  • DD TDD ceremony produces auditable evidence even for tickets where no code change is needed. The process works for “verify and document” just as well as “build and test.”
  • Preserving disagreements verbatim in retro ceremonies leads to better-scoped decisions than forced consensus.
  • The MCP enforcement model (block violations mechanically, don’t warn) eliminates an entire class of “we meant to follow the process” failures.

Sprint 2: Five Retro Decisions

The Sprint 1 retrospective produced five decisions that define Sprint 2’s scope. Each emerged from a specific failure or friction point observed during delivery.

D1: Shared Utilities (HIGH)

Owner: Tess Ter (QA Engineer)

Consolidate duplicated test fixtures into shared modules:

  • tests/utils/devto-test-utils.ts — shared rate-limit handling for Dev.to API tests
  • tests/fixtures/sensitive-patterns.ts — API key format samples used across skip-guard, format validation, and integration tests

Why: Three test files currently copy-paste the same fixture data. When we added underscore detection to API key patterns (a bug TDD caught in Sprint 1), we had to update the same data in multiple places. Shared fixtures mean one update, zero drift.

D2: Result Type Migration (MEDIUM)

Owner: Archi Tect (Principal Solution Architect)

Convert internal services from try-catch to Result<T,E>:

  • database-manager.ts → Result pattern
  • startup-validator.ts → Result pattern
  • HTTP-boundary services (devto-api.ts) stay with try-catch

Why: The Result<T,E> type introduced in Sprint 1 (OAS-091) makes error handling explicit — callers must check isOk() or isErr() before accessing values. But only new services use it. Migrating the two most critical internal services creates a clear boundary: internal = Result, HTTP = try-catch. An ADR will document this boundary.

D3: Migration Framework (HIGH)

Owner: Query Quinn (Database Administrator)

Forward-only numbered SQL migrations with version tracking:

  • Migration files: 001_initial.sql, 002_add_index.sql, etc.
  • Migration table tracking which versions have been applied
  • ADR required before implementation

Why: Without migrations, schema changes are manual and untracked. You can’t deploy to a second environment without manually recreating every schema change. You can’t roll forward reliably. This blocks any path to multi-environment or CI-driven deployments.

D4: Structured Observability (MEDIUM)

Owner: Pip Line (DevOps) + React Ive (Frontend)

Connect the startup validator to the health dashboard:

  • Startup validator emits structured JSON events (not just log lines)
  • Health dashboard auto-refreshes on a configurable interval
  • Last-updated indicator shows when data was last fetched
  • Pause/resume toggle for auto-refresh

Why: The health dashboard (Health.tsx, built in Sprint 1) and the startup validator exist but don’t talk to each other. The dashboard shows static data. The validator logs to console. Connecting them creates the observability loop that operators need.

D5: Path Convention (LOW)

Owner: Api Endor (Backend Developer)

Enforce path.join() / path.resolve() over string concatenation:

  • Lint rule or convention entry flagging string-concatenated paths
  • Audit existing codebase for violations

Why: Windows path separators () broke tests in Sprint 1. The fix was path.join(), but nothing prevents the next developer (human or AI) from writing dir + '/' + file again. A convention with enforcement prevents regression.

How We’ll Execute

Every ticket follows the DD TDD ceremony:

  1. Recall memory from previous tickets
  2. Update documentation describing intended behavior
  3. Bind documents to the ticket
  4. Write failing tests (RED) with evidence
  5. Implement to pass (GREEN) with evidence
  6. Refactor with evidence
  7. Validate all tests pass with full output
  8. Store lessons learned

Each phase requires a logged comment with real evidence — test names, assertion details, failure output, implementation summaries. The MCP server blocks phase transitions without adequate comments. No shortcuts.

Commits follow [TICKET-ID] description format. Push happens at sprint close, not per ticket.

What’s After Sprint 2

The V3 inception is complete. Eight sprints are planned covering:

  • Content sourcing with journalist-grade provenance
  • Audio engine with multi-voice TTS
  • Media orchestration engine
  • YouTube video pipeline
  • Podcast production and distribution
  • Quality gates and human experience
  • UI integration and unified dashboard

Sprint 2 is the bridge — cleaning up Sprint 0-1 technical debt so the V3 features have a solid foundation to build on.

Provenance

Field Value
Sprint Sprint 2 (pre-execution)
Author Content Curator persona, Claude Opus 4.6
Date 2026-03-28
Source: Sprint 0 metrics OAS-072-T1 work log: 18 tickets, 468 tests
Source: Sprint 1 metrics OAS-074-T1 work log: 17 tickets, 925+ tests
Source: D1-D5 decisions OAS-074-T4 retrospective summary
Source: Platform tool count V2 manifest: 102 MCP tools
Source: LinkedIn pages 4 active: ORCHESTRATE Method, Run On Rhythm, LEVEL UP, I am HITL
Temporal claims “Sprint 0” and “Sprint 1” refer to completed sprints as of 2026-03-28
Data sensitivity Checked — no API keys, credentials, or PII in post

This post is part of a series documenting the ORCHESTRATE Marketing Platform build. All development is AI-agent-driven with full traceability through the ORCHESTRATE Agile MCP framework.

Books on the ORCHESTRATE method: The ORCHESTRATE Method | ORCHESTRATE for AI Development | Platform: iamhitl.com

Part 2: what changed when I stopped treating my multi-agent system as an idea and started running it for real

In the first part, I explained why I ended up building a multi-agent flow instead of continuing to push everything into a single conversation. The idea still made sense: separating responsibilities, using different models depending on the phase, and keeping human approval before implementation gave me more order, better cost control, and less noise.

But at that stage I was still solving the conceptual problem.

This second stage was different. It was no longer about defending the idea, but about actually running it. And that was where the problems appeared that do not show up in a diagram or in a strong narrative: permissions, AI systems that do not behave the same way, processes that need a real interactive terminal, configuration that ages badly, and orchestration decisions that sound good on paper but do not hold up in practice.

The biggest change: I stopped thinking in terms of a pipeline and started thinking in terms of a runtime

I think the best way to explain this evolution is this: agentflow stopped being just a way to organize prompts, files, and steps, and started becoming an explicit runtime.

That changed the way I saw it.

Before, the configuration described more of what had to be generated. Now it describes how each role runs: which provider it uses, which model, which effort level, which sandbox, and which prompt governs it. It is no longer just a tool to assemble a flow, but a base for running real roles with more control.

That shift matters because it made me see something that was not fully clear before: separating roles well is not enough. You also need the runtime that makes that separation viable.

Real execution was what showed where the gap actually was

The clearest finding of this stage was simple and brutal: the agents could not write files autonomously.

Not because the design was missing. The sandbox field already existed and the intent was correct. The problem was more uncomfortable: the Claude adapter was not translating that intent into the real CLI flags. So the system ran, but it kept getting blocked asking for permission on every write.

That was one of those moments that forces you to land the idea in reality. Because that is when you understand that a system running and a system working are not the same thing.

The fix was direct, but the lesson mattered more than the fix. For Claude Code I had to translate workspace-write into --dangerously-skip-permissions and read-only into --permission-mode plan. Codex already handled that side more cleanly with --sandbox workspace-write. OpenCode, on the other hand, still has a more structural limitation because its CLI does not expose an equivalent flag.

You do not discover that problem by refining prompts. You discover it by running the system.

It also became clear that orchestration is not the same as delegation

Another thing this stage made very clear was that the orchestrator I had in mind was still not closing the last mile well enough.

In theory, agentflow run already existed and already had sequencing logic. But in practice, when Claude Code, Codex, or OpenCode were participating in a real session, that command was not enough. The bootstrap skills were too shallow. They mostly delegated and stopped there. They did not provide enough context to decide when to stop, which steps to run, how to handle the review loop, or when to ask for human approval.

That was when something became obvious that now feels self-evident: there is no single correct mode of orchestration.

CLI mode makes sense for automation, CI, and deterministic execution. But an interactive session needs something else. It needs the agent to have judgment to classify the task, present a plan, wait for approval before implementing, and decide how to move forward based on context. Trying to force the same mechanism to work both for automation and CI and for an interactive session with judgment and human approval created more friction than it removed.

Not every task deserves the full pipeline

Another improvement that feels genuinely important in this stage was finally grounding the classifier.

In part 1, the intuition was already there: not every task should cost the same. But it was still more of a thesis than a real system capability.

Now there is a role that classifies complexity as small, medium, or large, and that changes the flow. A small change does not need to go through the full ceremony. A larger task does justify the complete pipeline, a review loop, and possible model adjustments. And if a project comes from an older configuration and does not yet have that role, the system does not break: it falls back to a heuristic path and keeps running.

I like this because it moves optimization to the right place. Not after spending time and tokens, but before.

Put more simply: it makes no sense to treat a small bug like a complex migration.

Providers are not interchangeable

This stage also helped me let go of a simplification that is tempting in the abstract: thinking that all AI providers are more or less the same.

They are not.

When I talk about an AI provider, I mean the external system that executes a specific task inside the flow. It can be Claude, Codex, or something else. It is basically the service I delegate work to in one phase of the process.

And once I pushed this into real execution, it became clear that these providers do not behave in the same way. Permissions change, integration styles change, process handling changes, and even the way they expect to be run changes.

In some cases, it is also not enough to launch a command and wait for a response. Some tools need to run inside a real interactive terminal, as if they were opened directly in the console. That is usually called a TTY, but in plain language it means this: the tool needs a real terminal to work properly.

That is what pushed me toward different execution strategies depending on the provider. For some cases, a pipe-based execution worked fine. For Codex, I ended up needing a real PTY with node-pty, because its interface can fail or hang if it does not run in a genuinely interactive terminal.

It sounds like a minor detail, but it is not. Because working with agents is not only about working with text. It is also about processes, permissions, terminals, and real errors. And if that is not designed well, the whole system feels fragile even if the core idea is strong.

Several useful improvements were not flashy, but they were necessary

There were also less visible improvements that mattered more than they seem.

One was stopping the dependency on a rigid testRunner field in the config. That kind of field ages badly. You change the project, change the runner, or change the stack, and you end up carrying stale instructions. It felt much better to let the tester detect that from the project itself when the field is not defined.

These are not flashy changes, but they are the kind that make a tool stop feeling rigid.

Not everything is closed yet, but these are the right problems to have

I do not want to describe this stage as if everything were already perfect, because that would not be true.

There are still open gaps. The test suite passes, but it still does not cover the runtime-first contract deeply enough, especially around real adapter execution, agent run, and the classifier. The documentation is already much more aligned with the current runtime, but OpenCode still has a real sandbox limitation that does not depend only on agentflow.

But honestly, those already feel like healthy problems.

Because I am no longer debating whether the idea makes sense. I am no longer in the phase of defending the thesis. I am now in the phase of closing concrete gaps: compatibility, documentation, robustness, and execution consistency.

And I strongly prefer being there.

What part 2 really left me with

If part 1 was about why a multi-agent system made more sense than one giant conversation, part 2 is about something else: what happens when that idea leaves the page and meets reality.

That was where the real gaps showed up: permissions, effective orchestration, complexity management, providers that do not behave the same, processes, fragile defaults, and traces.

The original idea did not collapse. If anything, it came out stronger.

But now I see it more completely: a multi-agent architecture is not ready just because it looks good in the design. It is ready when it can actually run without breaking on basic things.

Closing

The first version taught me how to separate responsibilities.

This second stage forced me to build the runtime that makes that separation viable.

And real execution ended up teaching me the most important thing: between this runs and this works the way it should, there is a large distance. That distance is not closed with more theory. It is closed by running the system, observing where it fails, and correcting it with concrete changes.

That, for me, is what this second part is really about.

External resources

How to Add Role-Based Access Control to Next.js 16 with Auth.js v5

Most SaaS apps start simple: one user type, one set of features. Then a customer asks for a team plan, an admin dashboard, or a read-only guest mode — and suddenly you need roles.

Role-Based Access Control (RBAC) is how you handle this cleanly. In this guide we’ll add it to a Next.js 16 app using Auth.js v5, Prisma, and TypeScript — with real code you can drop in today.

What We’re Building

Three roles: USER, ADMIN, VIEWER. We’ll:

  1. Store roles in the database
  2. Inject them into the Auth.js session
  3. Protect pages via middleware
  4. Protect API routes with a helper
  5. Conditionally render UI based on role

Step 1: Add the Role to Your Prisma Schema

// prisma/schema.prisma

enum UserRole {
  USER
  ADMIN
  VIEWER
}

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  name          String?
  role          UserRole  @default(USER)
  emailVerified DateTime?
  image         String?
  accounts      Account[]
  sessions      Session[]
  createdAt     DateTime  @default(now())
  updatedAt     DateTime  @updatedAt
}

Run the migration:

npx prisma migrate dev --name add-user-role

Step 2: Extend the Auth.js Session

By default Auth.js doesn’t include custom fields in the session. We need to tell it to.

// auth.ts
import NextAuth from "next-auth"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import type { UserRole } from "@prisma/client"

export const { handlers, auth, signIn, signOut } = NextAuth({
  adapter: PrismaAdapter(prisma),
  providers: [/* your providers */],
  callbacks: {
    async session({ session, user }) {
      // Attach role to the session token
      session.user.role = user.role as UserRole
      return session
    },
  },
})

Then extend the TypeScript types so your editor knows about it:

// types/next-auth.d.ts
import type { UserRole } from "@prisma/client"

declare module "next-auth" {
  interface User {
    role: UserRole
  }
  interface Session {
    user: {
      id: string
      role: UserRole
    } & DefaultSession["user"]
  }
}

Step 3: Protect Pages with Middleware

Next.js middleware runs at the edge before a page renders — perfect for role gating.

// middleware.ts
import { auth } from "@/auth"
import { NextResponse } from "next/server"

export default auth((req) => {
  const { pathname } = req.nextUrl
  const session = req.auth

  // Unauthenticated: redirect to login
  if (!session && pathname.startsWith("/dashboard")) {
    return NextResponse.redirect(new URL("/login", req.url))
  }

  // Admin-only routes
  if (pathname.startsWith("/dashboard/admin")) {
    if (session?.user.role !== "ADMIN") {
      return NextResponse.redirect(new URL("/dashboard", req.url))
    }
  }

  return NextResponse.next()
})

export const config = {
  matcher: ["/dashboard/:path*"],
}

This is a hard gate — non-admins can’t even load the page.

Step 4: Protect API Routes

For API endpoints, use a helper that reads the session and checks role:

// lib/auth-guard.ts
import { auth } from "@/auth"
import type { UserRole } from "@prisma/client"

export async function requireRole(
  requiredRole: UserRole | UserRole[]
): Promise<void> {
  const session = await auth()

  if (!session?.user) {
    throw new Response("Unauthorized", { status: 401 })
  }

  const allowed = Array.isArray(requiredRole)
    ? requiredRole.includes(session.user.role)
    : session.user.role === requiredRole

  if (!allowed) {
    throw new Response("Forbidden", { status: 403 })
  }
}

Use it in a route handler:

// app/api/admin/users/route.ts
import { NextResponse } from "next/server"
import { requireRole } from "@/lib/auth-guard"
import { prisma } from "@/lib/prisma"

export async function GET() {
  try {
    await requireRole("ADMIN")
  } catch (res) {
    return res
  }

  const users = await prisma.user.findMany({
    select: { id: true, email: true, name: true, role: true },
  })

  return NextResponse.json(users)
}

Step 5: Role-Conditional UI

In Server Components, read the session directly:

// app/dashboard/layout.tsx
import { auth } from "@/auth"

export default async function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const session = await auth()
  const isAdmin = session?.user.role === "ADMIN"

  return (
    <div className="flex">
      <nav>
        <a href="/dashboard">Home</a>
        {isAdmin && <a href="/dashboard/admin">Admin Panel</a>}
      </nav>
      <main>{children}</main>
    </div>
  )
}

In Client Components, use the useSession hook:

"use client"
import { useSession } from "next-auth/react"

export function AdminButton() {
  const { data: session } = useSession()

  if (session?.user.role !== "ADMIN") return null

  return <button>Admin Action</button>
}

Note: Client-side role checks are for UX only. Always enforce roles server-side in middleware or API routes — never trust the client.

Bonus: Promote a User to Admin

A quick Prisma script to promote yourself during development:

// scripts/promote-admin.ts
import { prisma } from "@/lib/prisma"

async function main() {
  await prisma.user.update({
    where: { email: "[email protected]" },
    data: { role: "ADMIN" },
  })
  console.log("Done.")
}

main()
  .catch(console.error)
  .finally(() => prisma.$disconnect())

Don’t Want to Wire All This Up Yourself?

RBAC, Auth.js v5, Prisma schema, Stripe subscriptions, and an AI chat layer — it’s a lot of boilerplate to assemble from scratch.

LaunchKit ships all of this pre-configured as a Next.js 16 starter kit:

  • ✅ Auth.js v5 with role support baked in
  • ✅ Prisma schema with Users, Subscriptions, AI Chat
  • ✅ Admin dashboard with role-gated routes
  • ✅ Stripe billing + webhook handler
  • ✅ Dark mode, Tailwind CSS v4, TypeScript strict

$49 one-time. No subscription. Yours forever.

→ Get LaunchKit

Skip the boilerplate. Ship the thing.

Your AI Coding Agent Needs a Dev Environment Too

I’ve been watching how AI coding agents actually work — not the demos, the real day-to-day usage — and something keeps standing out: we’ve optimized the wrong things.

Agents have great editors now. They can read files, write files, run tests, search codebases, manage git. The tooling around code manipulation is genuinely impressive.

What they can’t do — what nobody seems to talk about — is run a development loop against the services their code depends on. And if you’ve built anything nontrivial in the last decade, that’s where most real development happens.

The development loop problem

When you build a feature that talks to an external API — Stripe for payments, your team’s user service, a third-party data provider — you need that service running and responding while you develop. You write code, test it, see what the API returns, adjust, test again. Tight loop.

AI agents do this loop too. Except faster. A lot faster.

A developer might hit an API endpoint 20–30 times while building a payment integration. They know the API, they’ve read the docs, they debug by reading error messages and adjusting carefully.

An AI agent doing the same work might hit that endpoint 200–400 times. It generates code, tests it, reads the response, adjusts, generates new code, tests again. Each iteration is cheap in human terms — it costs tokens, not hours — but each iteration still needs a real response from a real service.

And that’s where everything falls apart.

What happens when agents hit real services

Three problems, in order of how much they’ll derail your afternoon:

Rate limits. Stripe’s test mode allows 25 requests per second. That sounds generous until an AI agent burns through it in a tight iteration loop, gets rate-limited, spends 400 tokens parsing the 429 response, backs off, retries, gets limited again, and eventually asks you what went wrong. Your agent just hit a wall that has nothing to do with the code it’s writing.

Cost. Not every API has a free test mode. Third-party data providers, AI inference APIs, communication platforms — many charge per request even in sandbox environments. An agent making 200 exploratory calls during a feature build adds up fast. And unlike a developer who reads docs and makes targeted calls, agents are exploratory by nature. They try things. They test edge cases. That exploration has a dollar sign attached when it hits real services.

Availability. The API you depend on is maintained by a team in another timezone. They pushed a breaking change to staging at 3 PM their time — 7 AM yours. Your agent starts working at 8 AM and immediately hits 500 errors on every call. It doesn’t know the staging environment is broken. It thinks its code is wrong. It spends 45 minutes rewriting perfectly good code to “fix” an error that isn’t its fault.

Developers recognize these situations instinctively. We check Slack, we ping the other team, we context-switch to something else. Agents don’t have that instinct. They see a broken response and conclude they need to write different code.

The gap in your local dev stack

We’ve actually solved most of the “AI agent needs local infrastructure” problem:

  • Filesystem? Agents have one.
  • Terminal? Built in.
  • Database? SQLite, Docker, your local Postgres — pick one.
  • Git? First-class support in every agent.
  • Editor/IDE integration? That’s… what they are.

What we haven’t solved: the services this application talks to over the network.

Your app makes HTTP requests to a user service. It connects to a WebSocket for real-time updates. It calls a gRPC service for search. It authenticates through an OAuth provider. Those services are remote, someone else owns them, and they may or may not be running right now.

That gap — the space between “my agent can edit code” and “my agent can develop against a real system” — is the bottleneck nobody’s addressing. Your agent has every tool it needs to write code, and no way to verify that code works against the services it’s designed to talk to.

Mock servers as agent infrastructure

I’ll be direct: this is the reason I built mockd.

Not because mock servers are a new concept. WireMock has been around since 2011. json-server since 2013. The idea of returning fake responses from a local server isn’t novel.

What’s new is the role. Mock servers aren’t just test utilities anymore. They’re development infrastructure that makes AI agents productive. The same way a local database makes a developer productive — it’s always there, it’s fast, it’s yours — a local mock server makes an agent productive against external service dependencies.

When you point an AI agent at a project that has a mock server running, the agent can:

  • Make unlimited API calls with zero rate limits
  • Get sub-millisecond responses instead of network-latency responses
  • Work against services that don’t exist yet (the other team hasn’t built them)
  • Work at 3 AM when staging is down for maintenance
  • Work in complete isolation from other developers and their agents
  • Hit intentional error responses you’ve configured, not accidental ones from broken staging

This isn’t a test-time convenience. It’s a development-time necessity.

Self-provisioning infrastructure

Here’s where it gets interesting. With MCP (Model Context Protocol), an agent doesn’t just develop against mock servers — it can manage them.

MCP is a standard protocol that lets AI agents call external tools programmatically. mockd exposes 18 MCP tools for creating endpoints, importing API specs, managing state, and inspecting request logs. Any MCP-compatible agent — Claude Code, Cursor, GitHub Copilot — can use them.

The workflow:

  1. Agent reads the OpenAPI spec for the service you depend on
  2. Agent calls mockd’s import_mocks tool to create endpoints from the spec
  3. Agent develops against those endpoints as if they’re the real service

No human writes configuration. No human runs CLI commands. The agent provisions its own development infrastructure and immediately starts building.

This is a fundamentally different relationship between developer tools and AI. The tool isn’t something the developer configures for the agent. The tool is something the agent configures for itself.

The cost math

Let’s make this concrete.

A team of 10 engineers, each with an AI agent doing 4 hours of assisted development per day. Each agent averages 150 API calls to external service dependencies during that time.

Against real services:

Cost factor Calculation Daily cost
Metered API calls 10 agents x 150 calls x $0.01/call $15
Rate-limit delays ~5 min/day/agent of wasted time 50 min lost
Staging downtime 10% availability issues = blocked agents ~2 hrs/day team-wide
Agent token waste Debugging non-code errors (429s, 500s, timeouts) Hard to quantify, but real

Against local mock servers:

Cost factor Value
API calls $0.00
Rate-limit delays 0 ms
Availability issues 0 (it’s running on your machine)
Agent token waste Near zero

The mock server isn’t a nice-to-have optimization. It’s the difference between your agent spending tokens on your feature and spending tokens fighting infrastructure it doesn’t control.

This is just the beginning

Local mock servers for AI development is a bigger topic than one post can cover. Two things I haven’t addressed yet: why “return 200 OK with some JSON” isn’t enough (your agent needs realistic service behavior to build production-ready code), and how this scales when you have multiple teams with multiple agents all developing in parallel.

If you want to try this now:

# Install mockd
curl -fsSL https://get.mockd.io | sh

# Start the server
mockd start

# Create a mock endpoint
mockd add http --method GET --path /api/users 
  --status 200 
  --body '[{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]'

Your agent now has an API it can develop against. No rate limits, no costs, no downtime.

Learn more

  • Quickstart guide — install mockd and create your first mock in under 60 seconds
  • All features — MCP server, AI mock generation, 7 protocols, recording proxy, and more
  • All 7 protocols — HTTP, gRPC, GraphQL, WebSocket, MQTT, SSE, SOAP
  • Cloud tunnel — share local mocks via a public URL with one command

Links

Aave CAPO Oracle $27.78M Liquidation — Dev.to

When the Guard Dog Bites: The Aave CAPO Oracle Incident That Liquidated $27.78M
A deep dive into how Aave’s automated anti-manipulation oracle became the source of a $27.78M liquidation cascade, and what Web3 developers should learn from it.
The Incident in Brief
On March 10, 2026, Aave’s CAPO Oracle — an automated system designed to prevent oracle manipulation — pushed a faulty snapshotRatio update for wstETH. The oracle priced wstETH at ~1.19 ETH instead of the market rate of ~1.228849 ETH. A 2.85% discrepancy.
That was enough. 34 E-Mode positions were liquidated. 10,938 wstETH wiped out. Total value: approximately $27.78M.
No hacker. No exploit. The safety system itself fired on friendly forces.
Understanding CAPO Oracle
┌──────────────────────────────┐
│ Chaos Labs Edge Risk Engine │ ← Off-chain computation
└────────────┬─────────────────┘
│ Submit update
▼
┌──────────────────────────────┐
│ AgentHub (BGD Labs) │ ← On-chain execution layer
└────────────┬─────────────────┘
│ Execute (1 block)
▼
┌──────────────────────────────┐
│ Aave CAPO Oracle │ ← Price feed for protocol
└──────────────────────────────┘

Design philosophy: Speed over deliberation. No time-lock. No multisig review. If the system makes an error, it executes in the same one-block window designed for emergency response.
The Root Cause
The official post-mortem states: “a configuration mismatch between two parameters that should have moved in sync.”
contract CAPOOracle {
uint256 public snapshotRatio;
uint256 public referenceRate;

function getAssetPrice(address asset) external view returns (uint256) {
    if (asset == WSTETH) {
        uint256 basePrice = chainlinkFeed.latestAnswer();
        uint256 effectiveRatio = (snapshotRatio * referenceRate) / 1e18;
        return (basePrice * effectiveRatio) / 1e18;
    }
}

function updateParameters(
    uint256 newSnapshotRatio,
    uint256 newReferenceRate
) external onlyRole(AGENTHUB_ROLE) {
    // ⚠️ No consistency validation
    snapshotRatio = newSnapshotRatio;
    referenceRate = newReferenceRate;
}

}

The Liquidation Cascade
2.85% oracle error → 34 accounts liquidated → 10,938 wstETH → $27.78M gone.
The Two Narratives
Omer Goldberg (Chaos Labs CEO): “A misconfiguration on Aave’s CAPO oracle resulted in wstETH E-Mode liquidations, causing 345 ETH in losses. All affected users will be fully compensated.”
Stani Kulechov (Aave founder): “Technical misconfiguration caused the liquidation of positions that were already close to liquidation threshold.”
Lessons for Web3 Developers

  1. Automated Systems Need Automated Safeguards
    modifier withinSafetyBounds(uint256 newValue, uint256 oldValue) {
    if (oldValue > 0) {
    uint256 deviation = newValue > oldValue
    ? (newValue – oldValue) * 10000 / oldValue
    : (oldValue – newValue) * 10000 / newValue;
    if (deviation > MAX_DEVIATION_BPS) {
    emit SafetyBoundExceeded(oldValue, newValue, deviation);
    _scheduleTimelockedUpdate(newValue);
    return;
    }
    }
    _;
    }

  2. Multi-Parameter Systems Need Consistency Checks
    function updateParameters(uint256 newRatio, uint256 newCache) external onlyOperator {
    uint256 oldOutput = (snapshotRatio * exchangeCache) / 1e18;
    uint256 newOutput = (newRatio * newCache) / 1e18;
    uint256 outputDelta = newOutput > oldOutput ? newOutput – oldOutput : oldOutput – newOutput;
    require(outputDelta * 10000 / oldOutput <= MAX_OUTPUT_CHANGE_BPS, “Output deviation too large”);
    snapshotRatio = newRatio;
    exchangeCache = newCache;
    }

  3. Time-Locks Are Not the Enemy of Speed
    enum UpdateType { Routine, Emergency }

function scheduleUpdate(uint256 newPrice, UpdateType updateType) external onlyOperator {
uint256 delay = updateType == UpdateType.Emergency ? 0 : 30 minutes;
if (updateType == UpdateType.Emergency) {
require(hasValidMultisigSignature(), “Emergency requires multisig”);
}
_scheduleUpdate(newPrice, delay);
}

  1. Independent Monitoring Should Be a First-Class Concern
    LTV Protocol, a third-party monitor, was the first to publicly flag the anomaly — before Aave’s official post-mortem.
  2. Track Records Create Complacency
    CAPO had pushed 1,200+ payloads with zero failures. Then one failure caused $27.78M in liquidations.
    Conclusion
    The speed that makes CAPO effective against manipulation also makes it dangerous when it misfires.
    Automate safely. Validate consistency. Embrace monitoring. Design for failure.
    References:
  3. Parameter Update TX
  4. Execution TX
  5. Aave Post-Mortem
  6. LTV Protocol Alert

Printing Department

Advent of Code 2025 Day 4

The ease of Part 1 concerns me

I’ve solved this puzzle at least a dozen times throughout Advent of Code.

There is almost certainly going to be a unique twist in Part 2.

For now, it’s time to solve this yet again.

Writing another adjacent grid cell inspector

First, I want to ‘pad’ the grid with a border of .s so I can safely check each original cell without having to account for out-of-bounds errors.

I usually do this after converting the string into a 2-dimensional array.

But this time, I will opt to create a new string by adding characters from a stringified array and replacing newline characters with dot-padded newline characters.

Here’s the single, long concatenation statement:

let lineLength = input.indexOf('n')
input = new Array(lineLength + 2).fill('.').join('') 
    + 'n.' 
    + input.replaceAll('n', '.n.') 
    + '.n' 
    + new Array(lineLength + 2).fill('.').join('')

It turns this:

..@@.@@@@.
@@@.@.@.@@
@@@@@.@.@@
@.@@@@..@.
@@.@@@@.@@
.@@@@@@@.@
.@.@.@.@@@
@.@@@.@@@@
.@@@@@@@@.
@.@.@@@.@.

Into this:

............
...@@.@@@@..
.@@@.@.@.@@.
.@@@@@.@.@@.
.@.@@@@..@..
.@@.@@@@.@@.
..@@@@@@@.@.
..@.@.@.@@@.
.@.@@@.@@@@.
..@@@@@@@@..
.@.@.@@@.@..
............

And it works like a charm for any grid.

Next, my usual dictionary of relative coordinates for each adjacent cell:

let adjacents = [
    [-1,-1], [-1,0], [-1,1],
    [0,-1], [0,1],
    [1,-1], [1,0], [1,1]
]

Next, creating the grid, initializing the counter, and shell of the nested for loop:

let answer = 0
let grid = input.split('n').map(el => el.split(''))
for (let row = 0; row < grid.length; row++) {
    for (let col = 0; col < input[0].length; col++) {

    }
}

Lastly, checking each adjacent cell and tallying up the rolls:

answer += grid[row][col] == '@' 
    && adjacents
        .map(
            cell => grid[cell[0] + row][cell[1] + col] == '.' ? false : true
        )
        .filter(el => el == true).length < 4 ? 1 : 0

It is one statement that does the following:

  • Increment answer by 1…
  • As long as the current cell contains a @…
  • And the number of adjacent cells containing a @ is less than 4
  • Otherwise increment by 0

Testing it on the example input produces the correct answer, 13.

What about on my puzzle input?

…

I got the correct answer!

Nice!

And now…for Part 2’s likely diabolical twist.

Part 2

What a delightful twist

It is essentally Part 1 repeated until no change in state is produced.

Even as large as my grid is, I feel like brute-forcing this will still generate an answer fairly quickly.

I could be wrong, but that doesn’t change my approach to solve this part.

Building on Part 1

For Part 1, I processed a single iteration.

I now need a while loop to process as many iterations as it takes until no new rolls can be removed.

And I need to keep track of both the amount and location of rolls to be removed (a.k.a. turned into empty cells).

let changers = []
do {
    for (let row = 1; row < grid.length - 1; row++) {
        for (let col = 1; col < grid[0].length - 1; col++) {
            changers.push( grid[row][col] == '@' && adjacents.map(cell => grid[cell[0] + row][cell[1] + col] == '.' ? false : true).filter(el => el == true).length < 4 ? [row,col] : null)
        }
    }
    changers = changers.filter(el => el !== null)
    console.log(changers)
} while (changers.length > 0)

The code above successfully catalogs all cell locations of rolls that should be removed.

At least…after 1 iteration.

Since it never mutates the grid or clears the array, it repeatedly adds the same set of cell locations to the same array.

Time to fix that.

let changers = []
let answer = 0
do {
    answer += changers.length
    changers.forEach(cell => {
        grid[cell[0]][cell[1]] = '.'
    })
    changers = []
    for (let row = 1; row < grid.length - 1; row++) {
        for (let col = 1; col < grid[0].length - 1; col++) {
            changers.push( grid[row][col] == '@' && adjacents.map(cell => grid[cell[0] + row][cell[1] + col] == '.' ? false : true).filter(el => el == true).length < 4 ? [row,col] : null)
        }
    }
    changers = changers.filter(el => el !== null)
} while (changers.length > 0)

It was a simple addition of the forEach() loop early in the do-while loop.

I also added the answer variable to track the sum of removed rolls.

Running it on the example input generates the correct answer.

As always, fingers crossed it runs on my puzzle input…and generates the correct answer.

…

It worked!

Finished immediately and generated the correct answer!

Woohoo!!

That was a lot easer than I expected Part 2 to be.

It makes me worried for a much tougher Day 5.

Can’t wait to find out!

I Built a Free Astrocartography Calculator with Next.js — Here’s the Full Story

Most astrocartography tools online are either locked behind a $20/month paywall, built on Flash-era technology, or so cluttered with ads you can barely find the actual calculator. As a developer who stumbled into the world of astrology charts, I kept thinking: someone should just build a clean, free one.

So I did. Here’s the honest story of how I built Astrocarto — the technical decisions, the unexpected hard parts, and what I’d do differently.

What Even Is Astrocartography?

Quick background for the non-astrology crowd (which is probably most of you):

Astrocartography is a technique that takes the positions of planets at the exact moment you were born and projects them as lines onto a world map. The idea is that certain locations carry specific planetary energies for you — your “Venus line” might run through Paris, your “Mars line” through somewhere you’d rather avoid.

Whether or not you believe any of that — the math is genuinely fascinating. We’re talking about:

  • Calculating precise planetary positions using historical ephemeris data
  • Transforming between multiple astronomical coordinate systems
  • Projecting those coordinates onto a 2D geographic map

As a developer, I was hooked on the problem before I even thought about the product.

The Tech Stack

Framework:        Next.js 14 (App Router)
Styling:          Tailwind CSS
Map Rendering:    Leaflet + React-Leaflet
Astro Calc:       astronomia (npm)
Deployment:       Vercel

I’ll explain the key choices below.

Why Next.js?

Three reasons:

1. SEO is everything for a niche tool

People actively search for “free astrocartography calculator” — I needed server-side rendering so those pages could rank. With Next.js App Router, my calculator pages are fully rendered on the server before they hit the browser.

2. Route Handlers for calculation logic

I didn’t want heavy astronomical math running on the client. Next.js Route Handlers let me keep that on the server cleanly:

// app/api/calculate/route.js
export const runtime = 'nodejs'; // critical — don't use Edge here

export async function POST(request) {
  const { birthDate, birthTime, lat, lng } = await request.json();

  const chart = calculateNatalChart({ birthDate, birthTime, lat, lng });

  return Response.json({ chart });
}

3. Zero-config Vercel deployment

Push to main → live in 30 seconds. For an indie project, this matters.

The Hardest Part: Astronomical Calculations

I expected the map to be the hard part. I was wrong. The calculations nearly broke me.

Using the astronomia library

astronomia is a JavaScript port of Jean Meeus’s Astronomical Algorithms — the gold standard reference for this kind of math. It handles the heavy lifting of planetary position calculations.

Here’s a simplified version of how I get a planet’s ecliptic longitude:

import { solar, julian, base } from 'astronomia';

function getPlanetPosition(birthDate, birthTime) {
  // Convert birth datetime to Julian Day Number
  const dateStr = `${birthDate}T${birthTime}:00Z`;
  const jd = julian.DateToJD(new Date(dateStr));

  // Get Sun's position (as example)
  const sunPos = solar.apparentLongitude(jd);

  // Convert from radians to degrees
  const longitude = base.pmod(sunPos * 180 / Math.PI, 360);

  return longitude;
}

The coordinate system nightmare

Where it got painful: astrocartography lines require converting between three coordinate systems:

  1. Ecliptic coordinates — where planets are relative to Earth’s orbital plane
  2. Equatorial coordinates — right ascension + declination (like lat/lng for space)
  3. Geographic coordinates — actual latitude/longitude on Earth’s surface

For each planet, I need to find every latitude on Earth where that planet was on the Ascendant, Descendant, Midheaven, or IC at the moment of birth. That means iterating through latitudes and solving for the longitude where the condition holds.

function findAscendantLine(planet, jd) {
  const points = [];

  // Step through every latitude
  for (let lat = -80; lat <= 80; lat += 0.5) {
    // Binary search for the longitude where this planet rises
    const lng = solveForAscendant(planet, jd, lat);
    if (lng !== null) {
      points.push([lat, lng]);
    }
  }

  return points;
}

Getting this right took me about two weeks of head-scratching, cross-referencing with known charts, and fixing edge cases near the poles.

Rendering the Map with Leaflet

Once I had the coordinate arrays, rendering them with Leaflet was comparatively straightforward:

import { MapContainer, TileLayer, Polyline } from 'react-leaflet';

export function AstroMap({ planetLines }) {
  return (
    <MapContainer
      center={[20, 0]}
      zoom={2}
      style={{ height: '600px', width: '100%' }}
    >
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution="© OpenStreetMap contributors"
      />

      {planetLines.map(({ planet, points, color }) => (
        <Polyline
          key={planet}
          positions={points}
          pathOptions={{ color, weight: 2, opacity: 0.8 }}
        />
      ))}
    </MapContainer>
  );
}

One Leaflet gotcha with Next.js: Leaflet touches window on import, which breaks SSR. Fix:

// Use dynamic import with ssr: false
const AstroMap = dynamic(() => import('@/components/AstroMap'), {
  ssr: false,
  loading: () => <div className="map-skeleton" />
});

What Astrocarto Can Do Right Now

The tool currently supports:

  • ✅ Rising Sign Calculator — your ascendant based on exact birth time + location
  • ✅ Natal Chart — full planetary positions at birth
  • ✅ Transit Chart — current planetary positions overlaid on your natal chart
  • ✅ Relocation Chart — how your chart shifts if you move cities

All free. No account required. Just enter your birth details and go.

Try it: astrocarto.org

What I’d Do Differently

1. Validate calculations against existing tools first

I spent days debugging math that turned out to be correct — I just didn’t have a reliable reference to check against. Next time I’d set up automated comparison tests against established chart software from day one.

2. Build the map UI before perfecting the calculations

Seeing lines on a map, even approximate ones, keeps you motivated. I got too deep into calculation accuracy before I had anything visual to show for it.

3. Add caching earlier

Astronomical calculations for the same birth data always return the same result. I should have added a simple cache from the start rather than recalculating on every request.

// Simple in-memory cache I added later
const cache = new Map();

export async function POST(request) {
  const body = await request.json();
  const cacheKey = JSON.stringify(body);

  if (cache.has(cacheKey)) {
    return Response.json(cache.get(cacheKey));
  }

  const result = calculateNatalChart(body);
  cache.set(cacheKey, result);

  return Response.json(result);
}

What’s Next

  • [ ] Mobile-optimized map gestures
  • [ ] Shareable chart URLs (so you can send your chart to a friend)
  • [ ] PDF export
  • [ ] More celestial bodies — asteroids, Chiron, nodes

Building niche tools is genuinely one of my favorite things to do as an indie developer. The intersection of an obscure domain and a real technical problem is where the most interesting projects live.

If you’ve built something in a weird niche — astrology, birdwatching, chess, anything — I’d love to hear about it in the comments.

And if you’re curious about your own astrocartography: astrocarto.org — takes about 60 seconds.