From Elementor to Headless: How I Rebuilt My Portfolio with React and WordPress GraphQL

 The Challenge: Breaking Free from Page Builders

After years of building websites with Elementor and traditional WordPress themes, I decided to challenge myself with a complete portfolio rebuild using modern web technologies. The goal? Create a blazing-fast, scalable portfolio that separates content management from presentation while maintaining the flexibility WordPress offers.

What I built: A fully decoupled headless WordPress architecture with React powering the frontend.

Live URLs:

Why I Made the Switch

The Limitations I Faced with Elementor

Don’t get me wrong – Elementor is powerful and I’ve built countless successful sites with it. But as my skills evolved, I started hitting walls:

  1. Performance bottlenecks – Heavy page builders load lots of CSS/JS even when unused
  2. Limited customization – Want something truly custom? You’re writing CSS overrides everywhere
  3. Scaling issues – Managing 100+ pages with Elementor can get sluggish
  4. Modern development – No access to React ecosystem, component libraries, or modern build tools
  5. Mobile experience – While responsive, the mobile experience wasn’t as snappy as I wanted

What I Gained with Headless WordPress

The headless approach solved these issues:

✅ Lightning-fast performance – React optimizes rendering, Vite builds are tiny
✅ Complete design freedom – Build ANY UI without fighting CSS specificity
✅ Modern developer experience – React hooks, component libraries, TypeScript support
✅ Scalability – Frontend and backend scale independently
✅ Future-proof – Can swap frontend framework without touching content

The Technical Architecture

Backend: WordPress as a Headless CMS

What I kept:

  • WordPress Admin for content management
  • Custom Post Types (Portfolio, Blog)
  • Advanced Custom Fields for structured data
  • Media Library for asset management

What changed:

  • Removed ALL themes (using default WordPress)
  • Removed page builders completely
  • Exposed data via WPGraphQL instead of REST API
  • Created custom REST endpoints for contact form

Key plugins used:

  • WPGraphQL – Exposes WordPress data via GraphQL
  • WPGraphQL for ACF – Makes custom fields queryable
  • Custom Post Type UI – Creates custom post types
  • Advanced Custom Fields – Structures data

Why GraphQL over REST?

  • Fetch exactly what you need (no over/under-fetching)
  • Single endpoint for all queries
  • Strongly typed schema
  • Better developer experience with GraphiQL

Frontend: React with Vite

Tech stack:

  • React 18 – UI library
  • Vite – Build tool (faster than Create React App)
  • Apollo Client – GraphQL data fetching
  • React Router – Client-side routing
  • Swiper – Carousels/sliders
  • CSS Variables – Dynamic theming

Why Vite over Create React App?

  • 10-100x faster hot module replacement
  • Instant server start
  • Optimized production builds
  • Better developer experience

The Migration Process: Step by Step

Phase 1: Planning (1 week)

I audited my existing Elementor site:

  • Mapped out all pages and sections
  • Identified reusable components
  • Planned data structure in WordPress
  • Created wireframes for new design

Phase 2: WordPress Setup (3 days)

  1. Created custom post types (Portfolio, Services)
  2. Set up Advanced Custom Fields
  3. Installed and configured WPGraphQL
  4. Migrated content from old site
  5. Created custom REST endpoint for contact form
  6. Tested GraphQL queries in GraphiQL

Sample GraphQL query:

query GetPortfolio {
  portfolio {
    nodes {
      title
      content
      featuredImage {
        node {
          sourceUrl
        }
      }
      portfolioDetails {
        projectUrl
        technologies
        clientName
      }
    }
  }
}

Phase 3: React Development (2 weeks)

Component structure I built:

  • Navbar with mobile menu & dark mode toggle
  • Hero section with animations
  • About section with stats
  • Services grid (9 services)
  • Experience/Education timeline
  • Skills with animated progress bars
  • Blog list with category filtering
  • Single blog post with related posts carousel
  • Portfolio grid with modal details
  • Contact form with WordPress integration
  • Footer with social links

Key features implemented:

  • Dark/Light mode toggle (persists in localStorage)
  • Responsive design (mobile-first approach)
  • Smooth animations and transitions
  • SEO-friendly routing
  • Form validation and error handling
  • Loading states and error boundaries

Phase 4: Integration & Testing (1 week)

  1. Connected Apollo Client to WordPress GraphQL
  2. Implemented CORS handling
  3. Tested all queries and mutations
  4. Fixed edge cases and bugs
  5. Optimized images and assets
  6. Performance testing (Lighthouse)

Phase 5: Deployment (2 days)

Backend (WordPress):

  • Deployed to api.othmanewp.com
  • Configured SSL and CORS headers
  • Set up caching and security
  • Enabled GraphQL introspection

Frontend (React):

  • Built production bundle with Vite
  • Deployed to othmanewp.com
  • Configured routing and redirects
  • Set up analytics

The Results: Performance Comparison

Before (Elementor)

  • First Contentful Paint: ~2.8s
  • Time to Interactive: ~5.2s
  • Total Bundle Size: ~890KB
  • Lighthouse Score: 72/100

After (Headless React)

  • First Contentful Paint: ~0.8s ✅ (65% faster)
  • Time to Interactive: ~1.4s ✅ (73% faster)
  • Total Bundle Size: ~180KB ✅ (80% smaller)
  • Lighthouse Score: 96/100 ✅

Other improvements:

  • Mobile experience is significantly smoother
  • Navigation feels instant (client-side routing)
  • Dark mode toggle is instant
  • No layout shifts or jank
  • Better SEO with proper meta tags

Challenges I Faced (And How I Solved Them)

Challenge 1: Learning Curve

Problem: GraphQL was completely new to me

Solution:

  • Spent time in GraphiQL playground
  • Started with simple queries, built complexity gradually
  • Used Apollo Client DevTools for debugging
  • Followed WPGraphQL documentation

Challenge 2: CORS Issues

Problem: React couldn’t fetch from WordPress due to CORS

Solution:
Added headers to wp-config.php:

header('Access-Control-Allow-Origin: https://othmanewp.com');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

Challenge 3: Contact Form

Problem: No traditional form plugin support in headless setup

Solution:
Created custom WordPress REST API endpoint:

  • Validates and sanitizes form data
  • Sends email via wp_mail()
  • Saves to database
  • Returns JSON response to React

Challenge 4: Image Optimization

Problem: Large images from WordPress slowing down React app

Solution:

  • Used WordPress image sizes (thumbnail, medium, large)
  • Implemented lazy loading in React
  • Added loading=”lazy” attribute
  • Optimized images before upload

Challenge 5: SEO Concerns

Problem: Client-side rendering can hurt SEO

Solution (for now):

  • Proper meta tags in HTML
  • Semantic HTML structure
  • Fast page loads (good for SEO)

Future improvement: Consider Next.js for SSR/SSG

Code Quality & Best Practices

React Component Structure

components/
├── Navbar.jsx          # Reusable navigation
├── Footer.jsx          # Site footer
├── Hero.jsx            # Homepage hero
├── Blog.jsx            # Blog preview section
└── [...]

pages/
├── Home.jsx            # Homepage (combines components)
├── Blog.jsx            # Blog list page
├── BlogPost.jsx        # Single post page
├── Portfolio.jsx       # Portfolio grid
└── Contact.jsx         # Contact form

State Management

  • Used React Context for theme (dark/light mode)
  • Apollo Client cache for GraphQL data
  • Local state for forms and UI interactions

Styling Approach

  • CSS Modules for component styles
  • CSS Variables for theming
  • Mobile-first responsive design
  • No CSS framework (custom styles)

What I Learned

Technical Skills

  1. GraphQL – Query language and how it differs from REST
  2. Apollo Client – Data fetching, caching, state management
  3. Headless CMS architecture – Separation of concerns
  4. Modern React patterns – Hooks, Context, custom hooks
  5. Build optimization – Vite, code splitting, lazy loading
  6. WordPress as API – Using WordPress beyond themes

Soft Skills

  1. Planning is crucial – Spent 20% time planning, saved 50% dev time
  2. Documentation matters – Good docs = faster development
  3. Performance first – Think about performance from day one
  4. User experience – Speed matters more than fancy features
  5. Iteration works – Build, test, improve, repeat

Should You Go Headless?

When Headless Makes Sense ✅

  • You need maximum performance
  • You want modern development workflow
  • You’re comfortable with JavaScript
  • You need to scale frontend/backend separately
  • You want to use modern frameworks (React, Vue, Next.js)
  • You have complex, custom requirements

When to Stick with Traditional WordPress ⚠️

  • You need a site up quickly (hours, not weeks)
  • Your team isn’t technical (prefers visual builders)
  • You need WordPress plugins that modify frontend
  • Simple blog or brochure site
  • Limited development resources
  • SEO is absolutely critical (consider Next.js if going headless)

Future Plans

I’m not done yet! Here’s what’s next:

Short-term:

  • [ ] Add search functionality
  • [ ] Implement comments system
  • [ ] Add social sharing
  • [ ] Create sitemap generator
  • [ ] Add reading time to posts

Long-term:

  • [ ] Migrate to Next.js for SSR/SSG
  • [ ] Add internationalization (i18n)
  • [ ] Implement advanced analytics
  • [ ] Create admin dashboard for stats
  • [ ] Add progressive web app (PWA) features

Resources That Helped Me

Official Docs:

Communities:

  • WPGraphQL Slack
  • React Discord
  • Stack Overflow
  • WordPress Forums

Tools:

  • GraphiQL (GraphQL playground)
  • Apollo DevTools (Chrome extension)
  • React DevTools
  • Lighthouse (performance testing)

Key Takeaways

  1. Headless WordPress is powerful but requires more technical knowledge than traditional WordPress
  2. Performance gains are real – My site is 3-4x faster than with Elementor
  3. Development experience matters – Modern tools make coding more enjoyable
  4. It’s not for everyone – Assess your needs before jumping in
  5. The learning curve is worth it – Skills are transferable to other projects

The Numbers

Time Investment:

  • Planning & design: 1 week
  • WordPress setup: 3 days
  • React development: 2 weeks
  • Testing & deployment: 1 week
  • Total: ~4 weeks

Lines of Code:

  • React components: ~3,500 lines
  • CSS: ~2,800 lines
  • WordPress custom code: ~400 lines
  • Total: ~6,700 lines

Performance Improvement:

  • 73% faster time to interactive
  • 80% smaller bundle size
  • 96/100 Lighthouse score

Conclusion

Migrating from Elementor to a headless WordPress + React architecture was challenging but incredibly rewarding. I now have a portfolio that’s:

  • ⚡ Lightning fast
  • 🎨 Completely custom designed
  • 📱 Perfectly responsive
  • 🚀 Easy to maintain and scale
  • 💪 Built with modern best practices

Would I do it again? Absolutely. The skills I learned are applicable to any modern web project, and the performance gains speak for themselves.

Would I recommend it to everyone? No. If you’re happy with Elementor and it meets your needs, stick with it. But if you’re ready to level up your development skills and want maximum control over your site, going headless is an exciting journey.

Check It Out

Live Demo:

Open to feedback! What would you like to know about the migration process? Have you considered going headless? Drop your questions in the comments!

Tags: #WebDevelopment #WordPress #React #Headless #GraphQL #Frontend #ModernWeb #Performance #Elementor #Migration

About Me:
I’m a WordPress Developer specializing in headless architectures, custom theme development, and modern JavaScript frameworks. Currently working with React, Laravel, and Tailwind CSS to build fast, scalable web applications.

Connect with me:

Have you migrated from a page builder to a headless setup? I’d love to hear about your experience!

I Ranked Every AI Code Editor in March 2026. The $2B One Came Last.

Originally published at news.skila.ai

Cursor just crossed $2 billion in annualized revenue. It doubled in three months. And in every power ranking I can find for March 2026, it finishes behind a $15/month editor most developers have never heard of.

That is the state of AI code editors right now. Revenue and quality have completely decoupled. The tool making the most money is not the tool shipping the best code. And the one winning benchmarks doesn’t even have a GUI.

I spent the past two weeks pulling data from LogRocket’s March 2026 power rankings, SWE-bench Verified scores, developer satisfaction surveys, and pricing pages. Then I built a tier list. S-tier through C-tier. No hedging, no “it depends on your workflow” cop-outs.

Here is where every major AI code editor actually lands.

S-Tier: Claude Code (The Benchmark King With No GUI)

Claude Code sits at the top of this ranking for one reason: it produces the best code of any tool on the market, and the data is not close.

Claude Opus 4.6 — the model powering Claude Code — scores 80.8% on SWE-bench Verified. That is the highest score of any coding agent available today. In developer satisfaction surveys, Claude Code leads at 46%, ten points ahead of Cursor at 38%.

The 1M token context window (in beta) means Claude Code can hold an entire codebase in working memory. Not a few files. Not a module. The whole thing. For large monorepos, this is the difference between an assistant that understands your architecture and one that keeps asking you to paste more context.

Pricing: $20/month (region-dependent, up to $200).

The catch: It is a CLI tool. No visual editor. No file tree. No syntax highlighting panel. You need an existing editor setup and comfort with terminal workflows.

Verdict: If you care about output quality above everything else and you are comfortable in a terminal, nothing else comes close.

A-Tier: Windsurf and Cursor (The IDE Wars)

Windsurf: The Speed Monster at $15/Month

Windsurf took the #1 spot in LogRocket’s March 2026 power rankings. The reason is SWE-1.5.

SWE-1.5 is Windsurf’s custom model, served through a Cerebras partnership. It runs at 950 tokens per second. That is 13x faster than Claude Sonnet 4.5 and 6x faster than Haiku 4.5. Tasks that used to take 20+ seconds now finish in under 5.

Pricing: $15/month for Pro (500 prompt credits). Still cheaper than Cursor at every tier.

The catch: Developer satisfaction sits at 27% — the lowest of the Big 4. Fast does not always mean beloved.

Verdict: Best raw performance per dollar. If speed is your bottleneck, this is the answer.

Cursor: $2B Revenue, #3 Ranking

Cursor crossed $1B ARR in November 2025 and doubled to $2B by February 2026. Three months to double. The product is genuinely good — Cursor 2.0 introduced up to 8 parallel agents, Plan Mode, and a redesigned multi-agent interface.

Pricing: $20/month Pro.

The catch: It is more expensive than Windsurf at every tier and slower on raw benchmarks. The $2B in revenue comes from brand momentum and the best onboarding experience in the category, not from being technically superior.

Verdict: The safest pick. Not the best pick.

B-Tier: GitHub Copilot (The Value Play)

At $10/month, you get unlimited autocomplete, multi-model chat (GPT-4o, Claude Sonnet 4.6, Gemini 2.5 Pro), agent mode, and deep GitHub integration.

The catch: It is not an IDE. It is a plugin. The agentic capabilities lag behind Cursor, Windsurf, and Claude Code significantly. Developer satisfaction sits at 29%.

Verdict: The smart choice if you are cost-conscious and your workflow is primarily autocomplete + chat.

The Full Tier List (March 2026)

Tier Tool Price Key Metric
S Claude Code $20/mo 80.8% SWE-bench, 46% satisfaction
A Windsurf $15/mo 950 tok/s (13x Sonnet), #1 LogRocket
A Cursor $20/mo $2B ARR, 72% completion, 38% satisfaction
B GitHub Copilot $10/mo Best value, multi-model, 29% satisfaction
B Antigravity Free (preview) Multi-agent orchestration, Google backing
B Codex $20/mo Cloud-native, GPT-5 native, audit trails
C Gemini Code Assist Free–$22.50 VS Code extension, limited agentic
C Amazon Q Free–$19 AWS-focused, Copilot alternative
C Tabnine $12/mo Privacy-first, on-premise option

Three Takeaways That Actually Matter

1. Revenue is not quality. Cursor makes far more money than Windsurf. It is not better on benchmarks. Brand momentum explains the gap, not product superiority.

2. The CLI tool won. The highest-quality code comes from the tool with no GUI. Claude Code’s 80.8% SWE-bench score suggests the best interface for AI coding might be no interface at all.

3. Speed is underrated. Windsurf’s rise to #1 happened because of SWE-1.5’s raw throughput. 950 tokens per second changes the feel of AI-assisted coding from “waiting for the AI” to “keeping up with the AI.”

What are you using right now? I’m curious if anyone’s switched recently and what pushed the decision.

For more AI tool comparisons, the full article with benchmark sources is at news.skila.ai.

Começar em programação do zero depois dos 40: roteiro realista de André Blos Aliatti

O problema não é a idade, é a expectativa

Começar em programação depois dos 40 não é difícil do jeito que parece. Mas também não é simples do jeito que vendem. O problema não está na idade, está na forma como a entrada na área é apresentada. Existe uma narrativa muito forte de que basta escolher uma linguagem, fazer alguns cursos e, em poucos meses, tudo começa a acontecer. E quando isso não acontece — porque na maioria das vezes não acontece — a primeira coisa que vem não é clareza. É dúvida.

O início é confuso, não técnico

Eu sou André Blos Aliatti, desenvolvedor backend em transição de carreira, e o início da minha jornada não foi técnico. Foi confuso. Eu comecei como muita gente começa: consumindo conteúdo, tentando entender qual área fazia mais sentido, pulando entre possibilidades que pareciam promissoras. Dados, Python, automação, inteligência artificial. Tudo parecia relevante. Nada parecia definitivo. E esse é um dos primeiros pontos que ninguém deixa claro: no começo, você não sabe se está perdido porque a área é complexa ou porque você está fazendo errado. E essa dúvida cansa mais do que a dificuldade técnica.

O começo não é empolgante

O início da programação não é empolgante. Essa é outra parte que pouca gente fala. Você não começa construindo sistemas, não começa criando coisas impressionantes. Você começa tentando entender como um computador interpreta instruções simples. Variáveis, condições, repetição. Conceitos que parecem pequenos demais para quem já tem uma vida inteira de experiência em outras áreas. Existe até uma sensação incômoda de regressão, como se você estivesse voltando para um estágio muito básico. E é exatamente isso que está acontecendo. Só que esse básico não é opcional. Ele é estrutural.

A frustração vem da falta de sentido

A frustração não vem de não entender. Ela vem de não enxergar sentido. Você estuda, faz exercícios, acompanha aulas, mas não consegue ligar aquilo com algo real. E, sem essa conexão, o aprendizado parece solto. Foi só quando eu comecei a entender que programação não é sobre linguagem, mas sobre construção de sistemas, que as coisas começaram a se reorganizar. Antes disso, era como aprender palavras soltas de um idioma sem nunca formar uma frase.

O erro silencioso: estudar sem direção

Existe um erro silencioso que acontece nesse momento: estudar sem direção. Você aprende um pouco de cada coisa, acumula conhecimento fragmentado e passa a ter a impressão de que está evoluindo. Mas evolução de verdade só aparece quando existe continuidade. Quando você começa a aprofundar em um caminho específico. No meu caso, essa virada veio quando eu decidi parar de explorar tudo e comecei a focar em backend. Não porque era mais fácil, mas porque começou a fazer sentido estruturalmente. Principalmente quando entrei em orientação a objetos. Pela primeira vez, eu não estava só escrevendo código. Eu estava modelando algo que existia fora da tela.

A fase em que nada valida

Mas até chegar nesse ponto, tem um trecho que é mais pesado do que parece. É o trecho em que nada valida o que você está fazendo. Você estuda, se dedica, envia currículo, tenta vagas, e não recebe resposta. Nenhuma. E aí começam as perguntas que não são técnicas. São pessoais. Será que é a idade? Será que eu estou atrasado? Será que isso realmente é pra mim? Essa fase não aparece nos vídeos, não aparece nos posts, não aparece nos relatos rápidos de sucesso. Mas ela existe. E ela pesa.

O ponto de virada: construir

O que mudou para mim não foi encontrar o curso certo, nem a linguagem certa. Foi entender que aprendizado sem construção não sustenta. O momento em que você começa um projeto, mesmo sem saber direito o que está fazendo, muda completamente a dinâmica. Porque ali você para de consumir e começa a enfrentar problema. E problema obriga você a aprender de outro jeito. Não mais por curiosidade, mas por necessidade.

O que eu faria diferente hoje

Se eu tivesse que começar de novo hoje, não tentaria acelerar. Não tentaria escolher a stack perfeita. Eu começaria aceitando que o início é lento, que a base leva tempo e que a clareza não vem antes da prática. Vem durante. Começaria pequeno, construiria algo simples, erraria mais cedo e tentaria entender o porquê de cada erro. Porque no fim, o que faz diferença não é o quanto você estuda. É o quanto você consegue transformar estudo em construção.

A diferença de começar depois dos 40

Começar depois dos 40 muda algumas coisas, sim. Você não tem o mesmo tempo disponível, não tem a mesma margem para testar caminhos aleatórios, não tem o mesmo espaço para errar sem consequência. Mas, ao mesmo tempo, você tem uma vantagem que pouca gente valoriza: você leva o processo a sério. Você não está ali por curiosidade. Está porque precisa funcionar. E isso muda a forma como você aprende.

O problema nunca foi a idade

Hoje, olhando para trás, fica claro que o problema nunca foi começar mais tarde. Foi começar esperando um caminho mais linear do que ele realmente é. Programação não é uma linha reta. É um processo de construção que só faz sentido quando você atravessa as partes confusas, as partes lentas e as partes em que parece que nada está acontecendo.

Conclusão

Se você está nesse ponto agora, não tenta simplificar demais. Mas também não complica. Fica no processo. Aprende o básico de verdade. Escolhe uma direção. E constrói alguma coisa, mesmo sem estar pronto. Porque é aí que tudo começa a se encaixar.

Se quiser entender melhor minha trajetória, vale conhecer quem é André Blos Aliatti, acompanhar os projetos que venho desenvolvendo e ver como essa jornada está evoluindo na prática. Porque, no final, aprender programação não é sobre entender tudo rápido. É sobre continuar, mesmo quando ainda não faz sentido completo.

Adding a Scripting Engine to a Rust CLI with Rhai

diesel-guard is a linter for Postgres migrations. It catches operations that lock tables or cause downtime before they reach production. It ships with 28 built-in checks, but over time, users started asking for custom checks:

“Our DBA requires all indexes to follow the naming convention idx_<table>_<column>. Can I enforce that?”

“Our team convention is that every new table must have an updated_at column. Can diesel-guard catch tables that don’t?”

Each request is completely reasonable. But each one is unique enough that it did not make sense to add a built-in check for it. So telling users “open a PR” for a one-off team convention felt like the wrong answer.

The solution was to let users write the rule themselves, as a script, loaded at runtime, without recompiling. This post covers how I did it with Rhai.

Why Rhai

Rhai is an embedded scripting language written in Rust. You drop it into your project as you would any other crate, and scripts run within your process at runtime.

I came across this talk about it, found it interesting enough to give it a shot.

It turned out to work well for this use case. Add it to your Cargo.toml with two features:

rhai = { version = "1", features = ["serde", "sync"] }

sync makes the Rhai engine Send + Sync, which is required to store it behind a trait object. serde lets you serialize any Rust struct into a value that scripts can navigate. Both matter here, and you will see why shortly.

If you are evaluating Rhai for something performance-sensitive, the HN thread has a good discussion of the trade-offs.

The Architecture: One Trait, One Registry

Every check in diesel-guard, built-in or custom, implements the same trait:

use rhai::{AST, Dynamic, Engine};
use std::sync::Arc;

pub trait Check: Send + Sync {
    fn name(&self) -> &'static str;
    fn check(&self, node: &NodeEnum, config: &Config, ctx: &MigrationContext) -> Vec<Violation>;
}

NodeEnum is a pg_query protobuf enum with one variant per SQL statement type (IndexStmt, AlterTableStmt, CreateStmt, and so on). The Registry holds a Vec<Box<dyn Check>> and calls every check against every parsed statement.

Custom Rhai checks implement the same trait through CustomCheck:

pub struct CustomCheck {
    name: &'static str,
    engine: Arc<Engine>,
    ast: AST,
}

At check time, the registry has no way to tell whether it is calling a built-in Rust check or a Rhai script. They look the same from the outside.

To give you a sense of what this enables, here is the naming convention check from the intro. The whole thing is ten lines of Rhai:

let stmt = node.IndexStmt;
if stmt == () { return; }

let name = stmt.idxname;
if name == "" { return; }
if name.starts_with("idx_") { return; }

#{
    operation: "Index naming violation: " + name,
    problem: "Index '" + name + "' does not follow naming convention. Names must start with 'idx_'.",
    safe_alternative: "Rename: CREATE INDEX idx_" + name + " ON ...;"
}

Drop that file in a directory, point custom_checks_dir at it in diesel-guard.toml, and the check runs alongside every built-in check. How that works is what the rest of this post explains.

Bridging Typed Rust and Dynamic Scripts

This is the part that surprised me with how little code it actually needed.

pg_query gives us deeply nested protobuf types: Rust enums with dozens of variants, each with its own struct fields. Rhai scripts cannot import Rust types. The bridge is rhai::serde::to_dynamic(). Enable Rhai’s serde feature, and it serializes any Serialize type into a Dynamic value that scripts can navigate like a regular map.

fn check(&self, node: &NodeEnum, config: &Config, ctx: &MigrationContext) -> Vec<Violation> {
    let dynamic_node = rhai::serde::to_dynamic(node)?;
    let dynamic_config = rhai::serde::to_dynamic(config)?;
    let dynamic_ctx = rhai::serde::to_dynamic(ctx).unwrap();

    let mut scope = rhai::Scope::new();
    scope.push("node", dynamic_node);
    scope.push("config", dynamic_config);
    scope.push("ctx", dynamic_ctx);

    self.engine.eval_ast_with_scope::<Dynamic>(&mut scope, &self.ast)
}

On the script side, protobuf enum variants become map keys and struct fields become nested map keys. A NodeEnum::IndexStmt(stmt) becomes:

{ "IndexStmt": { "concurrent": false, "idxname": "idx_users_email", ... } }

So the guard pattern in scripts looks like this:

let stmt = node.IndexStmt;
if stmt == () { return; }  // wrong node type, skip

When the node is an IndexStmt, node.IndexStmt returns the inner map. When it is anything else, it returns (), and the script exits early.

To see exactly what a statement produces, there is a built-in subcommand:

diesel-guard dump-ast --sql "CREATE INDEX idx_users_email ON users(email);"
[
  {
    "IndexStmt": {
      "concurrent": false,
      "idxname": "idx_users_email",
      "index_params": [
        { "node": { "IndexElem": { "name": "email" } } }
      ],
      "relation": { "relname": "users" },
      ...
    }
  }
]

The output strips the RawStmt and Node wrappers. What you see is what the script receives as node.

Sandboxing: Resource Limits Are Enough

Scripts run inside a sandboxed engine with four limits:

fn create_engine() -> Engine {
    let mut engine = Engine::new();
    engine.set_max_operations(100_000);  // halt infinite loops
    engine.set_max_string_size(10_000);  // prevent memory leaks
    engine.set_max_array_size(1_000);
    engine.set_max_map_size(1_000);
    engine.register_static_module("pg", create_pg_constants_module().into());
    engine
}

When max_operations is hit, Rhai returns an ErrorTerminated. We catch that and treat it as “no violation”, so the linter continues normally:

Err(e) => {
    let err_str = e.to_string();
    if !err_str.contains("ErrorTerminated") {
        eprintln!("Warning: custom check '{}': runtime error: {e}", self.name);
    }
    vec![]
}

For a CLI tool checking local files written by your own team, this is sufficient. You are not running untrusted scripts from the internet.

Each invocation gets a fresh Scope, so no state leaks between migrations or between scripts.

A Stable Script API with the pg:: Module

pg_query protobuf enums are Rust types. Scripts cannot import them. If you want a script to check whether a statement is dropping an index versus dropping a table, it needs to compare stmt.remove_type against something, but that something is an integer that lives in a Rust enum.

The fix is to expose the values scripts need as a static module, registered on the engine at startup:

fn create_pg_constants_module() -> rhai::Module {
    use pg_query::protobuf::{AlterTableType, ConstrType, DropBehavior, ObjectType};

    let mut m = rhai::Module::new();
    m.set_var("OBJECT_INDEX", ObjectType::ObjectIndex as i64);
    m.set_var("OBJECT_TABLE", ObjectType::ObjectTable as i64);
    m.set_var("AT_ADD_COLUMN", AlterTableType::AtAddColumn as i64);
    m.set_var("AT_DROP_COLUMN", AlterTableType::AtDropColumn as i64);
    m.set_var("CONSTR_PRIMARY", ConstrType::ConstrPrimary as i64);
    // ...
    m
}

Scripts access them as pg::OBJECT_INDEX, pg::AT_DROP_COLUMN, and so on. If pg_query ever changes an enum value, there is one place to update it.

Return Protocol: Three Valid Shapes

Scripts return one of three things:

  • () for no violation
  • A map #{ operation: "...", problem: "...", safe_alternative: "..." } for one violation
  • An array of those maps for multiple violations

Validation is strict. A script with a typo in a key gets a helpful error violation rather than a silent failure:

SCRIPT ERROR: my_check
Custom check returned an invalid map: 'safe_alternative' is missing
Fix the custom check script to return all three required string keys.

Compilation errors at load time are non-fatal. The function signature makes this explicit:

pub fn load_custom_checks(
    dir: &Utf8Path,
    config: &Config,
) -> (Vec<Box<dyn Check>>, Vec<ScriptError>)

Broken scripts produce warnings to stderr. Valid scripts still load and run.

A Complete Script

Here is require_concurrent_index.rhai, one of the examples included in the repo. It covers node access, context usage, conditional messages, and both the single and multi-violation return shapes:

// Require CONCURRENTLY on CREATE INDEX.
// Also flags CONCURRENTLY inside a transaction (PostgreSQL will error at runtime).
// Inspect: diesel-guard dump-ast --sql "CREATE INDEX idx ON t(id);"

let stmt = node.IndexStmt;
if stmt == () { return; }

if !stmt.concurrent {
    let idx_name = if stmt.idxname != "" { stmt.idxname } else { "(unnamed)" };
    return #{
        operation: "INDEX without CONCURRENTLY: " + idx_name,
        problem: "Creating index '" + idx_name + "' without CONCURRENTLY blocks writes on the table.",
        safe_alternative: "Use CREATE INDEX CONCURRENTLY:n  CREATE INDEX CONCURRENTLY " + idx_name + " ON ...;"
    };
}

// CONCURRENTLY cannot run inside a transaction block
if ctx.run_in_transaction {
    let idx_name = if stmt.idxname != "" { stmt.idxname } else { "(unnamed)" };
    let hint = if ctx.no_transaction_hint != "" {
        ctx.no_transaction_hint
    } else {
        "Run this migration outside a transaction block."
    };
    #{
        operation: "INDEX CONCURRENTLY inside a transaction: " + idx_name,
        problem: "CREATE INDEX CONCURRENTLY cannot run inside a transaction block. PostgreSQL will raise an error at runtime.",
        safe_alternative: hint
    }
}

A few things worth noticing. ctx.run_in_transaction is a bool passed from the migration runner, useful for catching CONCURRENTLY inside a transaction block, which Postgres rejects at runtime. ctx.no_transaction_hint carries framework-specific instructions for how to opt out of transactions in Diesel versus SQLx. Scripts can return early with an explicit return #{ ... } or let the last expression be the return value. Integers auto-coerce to strings in concatenation.

If this was useful and you work with Postgres, a star on diesel-guard goes a long way. It helps other teams find the tool before a migration causes an outage.

EU AI Act Compliance Will Fail Without an AI System Inventory. Here’s How to Build One.

Every EU AI Act compliance guide starts the same way: classify your AI systems by risk level, determine your role (provider, deployer, importer), and build the required documentation. That advice is correct and completely useless if you can’t answer the question that comes before all of it: what AI systems does your organization actually have?

Most organizations can’t answer that question. Not because they’re negligent, but because AI is no longer something you build and deploy deliberately. It’s embedded in the SaaS tools your teams already use. Your CRM has AI-powered lead scoring. Your customer support platform has an AI chatbot. Your HR tool uses AI for resume screening. Your engineering team is using Copilot. Your marketing team is using AI content generation. Your finance team is running AI-powered forecasting.

Each of these triggers EU AI Act obligations. Some of them trigger high-risk classification. And nobody in the organization has a complete list.

The August 2, 2026 deadline for full high-risk AI system compliance is five months away. The organizations that will be ready are the ones that start with the inventory, not the ones that start with the risk classification framework.

Why the Inventory Comes First

The EU AI Act is structured around two axes: what the AI system does (risk classification) and what your relationship to it is (provider, deployer, importer, distributor). Every obligation in the Act flows from these two determinations. You can’t make either determination if you don’t know the system exists.

Risk classification requires understanding what the AI system does, what decisions it influences, and what populations it affects. An AI system that scores job applicants is high-risk under Annex III. An AI system that generates marketing copy is minimal risk. You can’t classify what you haven’t identified.

Role determination requires understanding your relationship to each AI system. If you built it, you’re likely a provider with the heaviest obligations. If you’re using a vendor’s AI features, you’re a deployer with your own set of requirements. If you’re a European company reselling a US vendor’s AI product, you might be an importer. Each role carries different documentation, monitoring, and reporting obligations. You can’t assign roles for systems you don’t know about.

Technical documentation requirements under Article 11 are specific to each AI system. Intended purpose, design specifications, training data descriptions, performance metrics, human oversight measures. You can’t document systems you haven’t inventoried.

Post-market monitoring under Article 72 requires continuous tracking of AI system performance and incidents. You can’t monitor systems you don’t know are running.

The inventory isn’t a nice-to-have preliminary step. It’s the foundation that every other compliance activity depends on.

What Counts as an “AI System” for Inventory Purposes

The EU AI Act defines an AI system broadly. It’s not limited to the machine learning models your data science team builds. It includes any system that generates outputs such as predictions, content, recommendations, or decisions with some degree of autonomy. In practice, this means your inventory needs to capture four categories.

Internally built AI. Models your team trained and deployed. Custom LLM integrations. Internal tools that use AI for classification, recommendation, or decision-making. These are usually the easiest to find because someone in engineering built them deliberately.

AI features embedded in vendor products. This is where most organizations have the biggest blind spot. Your Salesforce instance has Einstein AI. Your Zendesk has AI-powered ticket routing. Your Workday uses AI for workforce planning. Your Slack has AI summarization. Each of these is an AI system under the Act, and as the deployer, you have obligations even though you didn’t build it. The vendor being compliant doesn’t make you compliant. Deployer obligations are separate from provider obligations.

Decisioning and scoring systems. Credit scoring, fraud detection, eligibility determination, risk assessment. Some of these predate the current AI wave but still fall under the Act’s definition if they use machine learning or statistical inference. Many of these are high-risk by default under Annex III.

Agent workflows. AI agents that take actions across systems, whether built internally or connected through tools like MCP servers, are AI systems with their own classification requirements. An agent that processes customer data, makes decisions, and takes actions across multiple platforms may trigger multiple obligation categories simultaneously.

The Minimum Fields Your Inventory Must Include

A compliance-ready AI system inventory isn’t a spreadsheet with system names. It needs enough information to drive risk classification, role assignment, and evidence generation. Here are the fields that matter.

System identification. System name, internal identifier, vendor (if external), version, deployment date. Basic metadata that lets you track and reference each system.

Ownership. Business owner, technical owner, and compliance contact. The EU AI Act requires clear accountability. “The engineering team” is not an owner. A named individual is.

Role classification. Are you the provider (you built it), deployer (you use it), importer (you brought it into the EU market), or distributor? This determines which articles of the Act apply to you for this specific system.

Intended purpose and user population. What does the system do, who uses it, and who is affected by its outputs? An AI system that recommends products to consumers has different obligations than one that screens job applicants. The intended purpose drives risk classification.

Data categories. What data does the system process? PII, PHI, financial data, biometric data, data relating to minors? Data categories affect both risk classification and GDPR intersection requirements.

Risk classification with rationale. Your preliminary risk classification (unacceptable, high, limited, minimal) with documented reasoning for why you assigned that level. This should reference specific Annex III categories where applicable.

Human oversight mechanism. How is human oversight implemented for this system? Who reviews outputs? What decisions require human approval? For high-risk systems, this is a specific documentation requirement under Article 14.

Connected tools and action surface. What other systems does this AI connect to? What actions can it take? An AI system that only generates text has a different risk profile than one that can modify databases, send emails, or execute transactions.

Evidence links. Pointers to technical documentation, test results, monitoring dashboards, and incident records. The inventory should be the index that connects to all supporting evidence.

Building the Inventory Without Boiling the Ocean

The biggest risk in the inventory process is trying to be comprehensive on day one and getting paralyzed. A practical approach builds the inventory in layers, starting with what you can find quickly and expanding systematically.

Start with procurement and SSO logs. Your procurement records show what SaaS tools you’re paying for. Your SSO provider shows what tools employees are actually logging into. Cross-reference these lists and flag every tool that has AI features. This alone will surface dozens of AI systems you need to inventory, and it takes a day, not a month.

Add AI questions to vendor intake forms. Every new vendor evaluation should include: does this product use AI or machine learning? What data does it process through AI? What decisions does AI influence? What controls exist for AI outputs? This prevents the inventory from going stale as new tools are adopted.

Survey engineering teams for internal AI. Ask each engineering team: what AI models, LLM integrations, or ML systems are you running in production? What are they connected to? This surfaces the internally built systems that procurement records won’t show.

Check for shadow AI. Browser-based AI tools (ChatGPT, Claude, Gemini) that employees use without organizational accounts won’t show up in SSO or procurement. Network-level detection or endpoint monitoring can identify traffic to AI service domains. This is the hardest category to inventory but potentially the highest-risk for GDPR violations.

Prioritize by risk indicators. Once you have a rough list, prioritize systems that process personal data, affect individuals’ rights, or make consequential decisions. These are the most likely to be high-risk under Annex III and should be fully documented first.

Classification Without Collapse

With the inventory populated, classification is the next step. The trap here is treating classification as a one-time project when it’s actually a continuous process.

Build triage rules that route systems to the right review depth. Systems that clearly fall into minimal risk (AI-powered spell check, content recommendation for entertainment) can be classified quickly with lightweight documentation. Systems that touch any Annex III category (employment, credit, law enforcement, education, critical infrastructure) need full review with legal and compliance involvement.

Set an escalation path for borderline cases. Some systems won’t have an obvious classification. An AI tool that “assists” hiring decisions but doesn’t make final determinations might or might not be high-risk depending on how much influence its outputs have. These need human judgment from someone who understands both the technology and the regulation.

Establish a review cadence. AI systems change. Models get updated. Features get added. A system classified as minimal risk today might add a feature next quarter that pushes it into high-risk territory. Quarterly review of the inventory against current system capabilities prevents classification from going stale.

Post-Market Monitoring and Continuous Evidence

The EU AI Act doesn’t just require compliance at deployment. It requires ongoing monitoring for high-risk systems. Article 72 mandates that providers establish post-market monitoring systems proportionate to the AI system’s risk level.

In practice, this means monitoring for model drift (is the system’s behavior changing over time), performance degradation (is accuracy declining), incident triggers (has the system produced harmful outputs), and policy violations (is the system operating outside its intended purpose).

The evidence from this monitoring needs to be retained and producible for auditors. This is where the inventory connects to the enforcement layer. Every AI system in the inventory should have associated monitoring that produces evidence automatically. If monitoring depends on someone remembering to run a check quarterly, it will fail. If monitoring runs continuously and produces records as a byproduct, the evidence exists when the auditor asks for it.

The Enforcement Gap

Here’s what the inventory reveals but doesn’t solve: knowing what AI systems you have doesn’t ensure they’re operating within policy. An inventory tells you that your customer support chatbot exists, processes customer PII, and is classified as limited risk. It doesn’t prevent the chatbot from leaking PII in a response, violating your data handling policies, or operating outside its intended purpose.

The inventory is the foundation. Policy enforcement is the mechanism that makes the inventory actionable. Each system in the inventory should have associated policies that are enforced in real time, with violations detected and addressed before they become compliance incidents.

The organizations that build the inventory first and then connect it to continuous enforcement will be the ones that pass audits. The ones that build the inventory as a spreadsheet and leave it disconnected from runtime operations will be the ones scrambling to produce evidence when regulators come asking.

The Five-Month Countdown

August 2, 2026 is five months away. For organizations that haven’t started, the inventory is the highest-leverage first step. It surfaces what you have, enables classification, identifies your highest-risk systems, and creates the structure that enforcement and monitoring build on.

The practical sequence for the next five months: month one, build the inventory using procurement, SSO, and engineering surveys. Month two, classify systems and identify high-risk candidates. Month three, produce technical documentation for high-risk systems. Month four, implement monitoring and enforcement for high-risk systems. Month five, dry-run an internal audit and close gaps.

This timeline is aggressive but achievable if you start with the inventory instead of starting with the framework. The framework tells you what to do. The inventory tells you what to do it to. Start with what you have. Everything else follows.

I’m building Aguardic — a policy-as-code platform that connects your AI system inventory to continuous policy enforcement. Register your AI systems, attach policies, and enforce them in real time with audit-ready evidence generated automatically. Happy to answer questions about EU AI Act compliance in the comments.

Stripe Tempo Goes Live: AI Agent Payment Rails

Stripe Just Launched Its Own Blockchain — And It Changes Everything for Payment Developers

On 18 March 2026, Stripe-backed Tempo went live on mainnet, bringing a payments-optimised blockchain into production alongside a new open standard: the Machine Payments Protocol (MPP). For fintech developers and crypto payment engineers, this is a watershed moment. The company that processes over $1 trillion in annual payment volume is now building its own settlement layer — purpose-built for stablecoins and AI agent transactions.

And Stripe is not alone. Circle launched USDC Nanopayments on testnet the same month, enabling gas-free micro-transfers down to $0.000001. The race to become the default payment rail for autonomous AI agents is officially underway.

What Is Tempo and Why Does It Matter?

Tempo is a payments-focused blockchain co-developed by Stripe and Paradigm. Unlike general-purpose chains optimised for DeFi or NFTs, Tempo is engineered specifically for high-throughput stablecoin settlement. The numbers speak for themselves:

  • 100,000 transactions per second — orders of magnitude beyond Ethereum’s base layer
  • Sub-second finality — critical for real-time payment confirmation
  • Native stablecoin support — built from the ground up for dollar-pegged assets

The design partners read like a who’s who of global finance and technology: Visa, Mastercard, Deutsche Bank, Standard Chartered, Revolut, Nubank, Shopify, OpenAI, Anthropic, Ramp, and DoorDash. When both card networks and AI labs are building on the same infrastructure, the signal is clear — this is where payment settlement is heading.

The Machine Payments Protocol: OAuth for Money

The real innovation is not the blockchain itself but what runs on top of it. MPP introduces a concept called “sessions” — essentially OAuth for money. Here is how it works:

  1. An AI agent authenticates and sets a spending cap
  2. The agent streams micropayments continuously as it consumes services (data, compute, API calls)
  3. Payments settle in stablecoins without human approval at each step
  4. Service providers receive real-time confirmation of payment

This is fundamentally different from traditional payment flows. There is no checkout page, no card authorisation, no 3D Secure challenge. The agent pays as it goes, much like how a developer’s API calls are metered and billed — except settlement happens in real time on-chain.

Visa contributed to the MPP specification, developing standards for letting agents pay with traditional credit or debit cards alongside stablecoin rails. This hybrid approach — crypto-native settlement with card network fallback — is precisely the kind of infrastructure that payment developers need to understand.

Read the full article on tomcn.uk →

About the Author

I’m Tom Wang, a Founding Engineer at Radom building crypto payment infrastructure, Open Banking integrations, and cross-border payout systems with Rust and Go. Based in London, UK.

Currently open to new opportunities in fintech, crypto payments, and AI agent engineering.

I Built a Chrome Extension That Lets You Inspect CSS Without Opening DevTools

Every front-end developer knows the drill: right-click → Inspect → scroll through a mountain of inherited styles in the DevTools panel just to find the CSS of one button.

I got tired of it. So I built SnapCSS — a Chrome extension that lets you see the CSS of any element on any page with a single click.

How It Works

  1. Click the SnapCSS icon in your toolbar to activate inspect mode
  2. Hover over any element — it gets highlighted instantly
  3. Click the element — a clean floating panel shows you the full, cleaned CSS
  4. One-click copy to your clipboard

That’s it. No DevTools, no noise, no digging through inherited rules.

Why I Built This

DevTools is powerful, but it’s overkill when all you want is “what’s the font-size, padding, and background of this button?” As a front-end developer, I do this dozens of times a day — checking how other sites style their components, debugging spacing issues, or just grabbing a quick reference.

SnapCSS strips away the complexity and gives you exactly what you need: the computed CSS of the element you clicked, formatted and ready to copy.

What Makes It Different from Similar Tools

  • Lightweight — only 61.4 KB, no bloat
  • Clean output — shows cleaned, relevant CSS instead of dumping every inherited property
  • Beautiful floating panel — stays on the page, doesn’t take you to a separate view
  • Zero data collection — no analytics, no tracking, your browsing stays private

Install It

SnapCSS is free on the Chrome Web Store:

👉 Install SnapCSS

If you find it useful, a rating on the Chrome Web Store would mean a lot — it helps other developers discover it.

What’s Next

I’m currently working on another extension called WebMCP DevTools for debugging the new WebMCP protocol (the standard that lets AI agents interact with websites). It’s currently in review on the Chrome Web Store.

If you have feedback or feature requests for SnapCSS, drop a comment below — I’d love to hear what you think.

Building a Multi-Tenant SaaS with Stripe Connect in 2026

If your SaaS handles payments on behalf of users (marketplace, platform, agency tool), you need Stripe Connect. Here’s the architecture that actually works, based on building one from scratch.

The core problem

Your SaaS has multiple customers. Each customer has their own end-users who pay them. You need to:

  1. Let each customer connect their own Stripe account
  2. Process payments from their end-users into their Stripe account
  3. Take a platform fee from each transaction
  4. Handle webhooks for all connected accounts
  5. Keep everyone’s data isolated

This is what Stripe Connect solves. But the docs are 200+ pages and most tutorials skip the hard parts.

Picking the right Connect type

Stripe offers three account types: Standard, Express, and Custom. Here’s the actual decision:

Standard accounts (recommended for most SaaS): Your customer already has a Stripe account or creates one during onboarding. They manage their own payouts, disputes, and compliance. You just create charges through their account.

Express accounts: You want a lighter onboarding flow. Stripe hosts the dashboard for your connected accounts. Good for marketplaces where sellers don’t need full Stripe features.

Custom accounts: You build the entire onboarding UI yourself and handle compliance. Only use this if you need complete white-labeling. The compliance burden is significant.

For most SaaS platforms, Standard accounts win. Your customers keep their existing Stripe relationship, Stripe handles compliance, and your integration is simpler.

The OAuth flow

Standard Connect uses OAuth. Here’s the flow:

// 1. Generate the connect URL
const connectUrl = `https://connect.stripe.com/oauth/authorize?` +
  `response_type=code&` +
  `client_id=${process.env.STRIPE_CLIENT_ID}&` +
  `scope=read_write&` +
  `redirect_uri=${process.env.NEXT_PUBLIC_URL}/api/stripe/callback&` +
  `state=${organizationId}`; // CSRF protection

// 2. User clicks "Connect Stripe" -> redirects to Stripe
// 3. After approval, Stripe redirects back with an authorization code

// 4. Exchange the code for an account ID
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  const state = searchParams.get('state'); // your org ID

  const response = await stripe.oauth.token({
    grant_type: 'authorization_code',
    code,
  });

  // response.stripe_user_id is the connected account ID
  // Save this: it's the only thing you need to charge on their behalf
  await db.organizations.update({
    where: { id: state },
    data: { stripeAccountId: response.stripe_user_id },
  });
}

The stripe_user_id is the permanent link between your customer and their Stripe account. Store it. You’ll use it for every API call on their behalf.

Creating charges on connected accounts

When an end-user pays, you create a PaymentIntent on the connected account:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 4900, // $49.00
  currency: 'usd',
  application_fee_amount: 490, // your 10% platform fee
  // This is the key: charge goes to the connected account
  transfer_data: {
    destination: organization.stripeAccountId,
  },
});

The application_fee_amount is your revenue. Stripe sends amount - application_fee_amount to the connected account. Clean, automatic, no manual splits.

The webhook architecture (this is the hard part)

For a multi-tenant SaaS, you need to handle webhooks from both your own Stripe account AND all connected accounts. Stripe sends Connect webhooks to a single endpoint.

// api/webhooks/stripe/route.ts
export async function POST(request: Request) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature');

  // Use the CONNECT webhook secret, not the regular one
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_CONNECT_WEBHOOK_SECRET
  );

  // event.account tells you WHICH connected account this is about
  const connectedAccountId = event.account;

  switch (event.type) {
    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object, connectedAccountId);
      break;
    case 'invoice.payment_succeeded':
      await handlePaymentSucceeded(event.data.object, connectedAccountId);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionCanceled(event.data.object, connectedAccountId);
      break;
  }

  return new Response('ok', { status: 200 });
}

Two critical details:

  1. Use the Connect webhook secret, not your regular webhook secret. These are different endpoints in the Stripe Dashboard (Settings > Webhooks > “Add endpoint” under “Connected accounts”).

  2. Make every handler idempotent. Stripe retries failed webhooks. Store the event.id and skip duplicates:

async function handlePaymentFailed(invoice, accountId) {
  // Idempotency check
  const existing = await db.webhookEvents.findUnique({
    where: { stripeEventId: event.id }
  });
  if (existing) return; // Already processed

  // Find the org by their connected account
  const org = await db.organizations.findFirst({
    where: { stripeAccountId: accountId }
  });

  // Schedule dunning sequence for this org's customer
  await scheduleDunningEmails(org.id, invoice);

  // Record the event
  await db.webhookEvents.create({
    data: { stripeEventId: event.id, type: 'payment_failed' }
  });
}

Data isolation with Row Level Security

Each organization’s billing data must be isolated. With Supabase/Postgres, RLS handles this:

-- Every billing-related table has an org_id column
ALTER TABLE recovery_campaigns ENABLE ROW LEVEL SECURITY;

CREATE POLICY "org_isolation" ON recovery_campaigns
  FOR ALL
  USING (org_id = auth.jwt()->>'org_id');

Your API never needs to filter by org manually. The database enforces isolation at the query level. This matters because a single webhook endpoint serves all connected accounts. Without RLS, a bug in your account-to-org mapping could leak data across tenants.

Testing Connect locally

Stripe CLI forwards webhooks to localhost. For Connect, use:

stripe listen --forward-connect-to localhost:3000/api/webhooks/stripe

The --forward-connect-to flag is different from --forward-to. It forwards Connect events (from connected accounts) instead of direct events. Get this wrong and you’ll spend an hour wondering why your webhook handler never fires.

Test payment failures:

# Create a subscription with a card that fails on the next payment
stripe customers create --stripe-account acct_CONNECTED_ID
stripe subscriptions create 
  --customer cus_xxx 
  --items[0][price]=price_xxx 
  --payment-settings[payment_method_types][0]=card 
  --stripe-account acct_CONNECTED_ID

The pricing model

Your platform fee structure matters. Three options:

Percentage fee (most common): Take 5-15% of each transaction via application_fee_amount. Simple, scales with customer revenue.

Fixed subscription + percentage: Charge customers a monthly SaaS fee via your own Stripe account, plus a smaller percentage on their connected account transactions.

Fixed subscription only: Monthly fee, no per-transaction charge. Simpler billing but you don’t benefit from customer growth.

For a billing/recovery SaaS like the one I built, percentage makes the most sense. You recover their failed payments, you take a cut of what you recovered. Aligned incentives.

Things that bit me

Webhook signature verification with raw body. Next.js App Router parses the request body by default. You need the raw body for signature verification. Use request.text() not request.json().

API version mismatches. If you hardcode an API version in your Stripe initialization (apiVersion: "2024-10-28.acacia") and then use a parameter that was added in a newer version, you’ll get cryptic errors. Either pin to the latest version or don’t pin at all.

Connect onboarding state. After OAuth, the connected account might not be fully set up (missing bank account, identity verification pending). Check account.charges_enabled and account.payouts_enabled before letting them use your platform. Show a setup checklist if they’re not complete.

Test mode vs. live mode keys. Connect webhook secrets are different between test and live mode. I had test mode working perfectly, deployed to production, and webhooks silently failed for 3 days because I was using the test webhook secret. Check your environment variables twice.

Summary

The architecture for a multi-tenant Stripe Connect SaaS:

  1. Standard accounts via OAuth (store stripe_user_id per org)
  2. PaymentIntents with transfer_data.destination and application_fee_amount
  3. Single Connect webhook endpoint with event.account routing
  4. Idempotent handlers keyed on event.id
  5. RLS for data isolation at the database level
  6. stripe listen --forward-connect-to for local testing

The hard part isn’t any single piece. It’s getting all of them right at the same time. Connect webhooks are particularly tricky because bugs are silent. Your webhook returns 200, Stripe is happy, but the handler did nothing because it used the wrong secret or missed the event.account field.

Build it methodically, test each event type with Stripe CLI, and check your webhook logs in the Dashboard. The webhooks page shows every delivery with the full payload and response.

I build production AI systems. If you’re working on something similar, I’m at astraedus.dev. My book Production AI Agents covers patterns like this in depth.

I Was Told to Write My Thesis in LaTeX. Here’s How I Actually Got Started.

My advisor’s email said: “Please submit your thesis in LaTeX format.”

I had two reactions: first, mild panic — I’d never written anything in LaTeX. Second, the overconfident thought that I could figure it out over a long weekend.

Spoiler: the weekend came and went. I was still staring at a wall of compile errors I couldn’t parse.

Three Attempts, Three Failures

Attempt 1: MacTeX + TeXShop

I downloaded MacTeX (the full 5GB installation — took forever), opened the bundled TeXShop editor, pasted in a sample document, hit compile, and got this:

! LaTeX Error: File `amsmath.sty' not found.

Spent an hour searching Stack Overflow. The suggested fix was to run tlmgr install amsmath in the terminal. Ran it. Got a different error. Gave up.

Attempt 2: VSCode + LaTeX Workshop

People on Reddit swore by this setup. So I installed VSCode, installed the LaTeX Workshop extension, copied a settings.json snippet from a blog post, and managed to compile a basic document. Then I tried to write Japanese text in it. Characters came out garbled. I spent ninety minutes debugging the encoding issue before abandoning this path too.

Attempt 3: Overleaf

Fine. I’ll use the browser-based one. No installation required, real-time preview, beautiful. Worked great right up until my thesis hit 80 pages and the free tier started timing out on compiles. The paid plan was $19/month. I wasn’t ready to commit.

What Finally Worked: TeX64

A classmate mentioned TeX64, a macOS-native LaTeX editor. I was skeptical — I’d already wasted hours on three other tools. But I tried it, and something clicked.

The First Thing I Noticed: It Tells You What’s Wrong

When I opened TeX64 for the first time, the app ran a quick diagnostic of my environment. Instead of a cryptic error, I saw something like:

⚠ latexmk not found. It looks like MacTeX isn’t installed. You can download it here: [link]

This might sound like a small thing. But after hours of Googling vague error messages, having software just tell me what the problem is felt almost revolutionary. I installed MacTeX by following the link, restarted TeX64, and it worked.

TeX64 runs entirely on your Mac — Apple Silicon and Intel both — with all compilation happening locally. Once MacTeX is installed, you don’t need an internet connection to write or compile.

Axiom: An AI That Actually Understands LaTeX Errors

Every LaTeX beginner eventually hits this wall: the compile fails with an error message that makes no sense to a human.

! Misplaced alignment tab character &.
l.34  & 2x + 3y

I’d stare at this for ten minutes, cross-reference it with a LaTeX cheat sheet, wonder if I was missing a package, and eventually just delete whatever I’d written and start over.

TeX64 has a built-in AI assistant called Axiom. When a compile fails, Axiom reads the error log alongside your .tex files and gives you a plain-English explanation:

“The & character on line 34 is being used outside an alignment environment like align or tabular. Try wrapping your equations in begin{align} ... end{align}.”

And then Axiom proposes a fix as a diff. You can review exactly what would change before applying it. It never silently modifies your files.

I went from “I’ll just delete this and rewrite it” to “okay, I understand what went wrong and I know how to fix it.” That mindset shift mattered a lot in the early weeks.

Building Equations Without Memorizing Commands

One of the things that makes LaTeX intimidating for beginners is that math requires learning a whole new vocabulary of commands.

int_{0}^{infty} e^{-x^2} , dx = frac{sqrt{pi}}{2}

TeX64 has a visual math palette: a panel where you click symbols and see the equation rendered in real time. Click the integral sign, set the bounds, and TeX64 writes the LaTeX for you. The generated code is clean and editable. You can see what int_{0}^{infty} looks like as you build it, and gradually the syntax starts to make sense.

After two weeks of building equations with the palette and seeing the corresponding code, I found I could write many of them from memory without thinking.

SyncTeX: Click PDF, Jump to Source

SyncTeX syncs your PDF preview and source file bidirectionally. Click anywhere in the PDF, and your editor jumps to the corresponding line in the .tex file.

When your thesis is 100+ pages across multiple files, finding the source of any given paragraph becomes a real chore. With SyncTeX, I just click in the PDF wherever I want to edit, and I’m there instantly.

Honest Limitations

macOS only. TeX64 won’t run on Windows or Linux.

You still need MacTeX. TeX64 is an editor, not a LaTeX compiler. You’ll need MacTeX or TeX Live installed separately. TeX64 walks you through this if it’s missing.

No real-time collaboration. Overleaf is still the better choice for simultaneous co-authoring.

AI features have usage limits on the free tier. Basic editing, compilation, and SyncTeX are free with no account required. Heavy use of Axiom or OCR requires a paid plan.

How to Get Started

  1. Go to tex64.com and download the app.
  2. Open TeX64 — it will detect if MacTeX is missing and guide you through installing it.
  3. Create a new document and try compiling the template.
  4. If anything goes wrong, ask Axiom.

TeX64 shortens the path from “I don’t know where to start” to “I’m actually writing LaTeX.” For Mac users, that’s worth a lot.

Check it out at tex64.com.

Choosing and Integrating Mobile Video SDKs: FFmpeg, ExoPlayer, and Commercial Options

  • A pragmatic evaluation rubric: performance, licensing, and feature fit
  • FFmpeg mobile, ExoPlayer, and MediaCodec — where each tool genuinely earns its keep
  • Commercial SDKs and when enterprise support actually repays the cost
  • Integration realities: maintenance, ABI churn, and the binary-size tax
  • Practical application: migration checklist and benchmarking protocol

Video is the single feature that will expose architectural compromises in seconds: dropped frames, battery complaints, and suddenly-visible license obligations. Choose the wrong video stack and you’ll pay in performance, team time, and sometimes legal review.

Playback stutters are rarely the UI team’s fault — they’re the symptom of a pipeline problem: wrong codec fallbacks, missing hardware-accel paths, ABI mismatches across Android ABIs, oversized native libraries that bloat installs, and licenses that weren’t reviewed until release. I’ve seen teams rebuild the same streaming stack three times because they optimized the wrong axis (size vs. latency vs. legal). You need a repeatable rubric and a minimal, instrumented migration path before you pick anything.

Important: Licensing is not a checkbox — it’s a constraint that changes engineering options (static linking vs. server-side processing) and release strategy. Check licensing early.

A pragmatic evaluation rubric: performance, licensing, and feature fit

You should evaluate any video SDK against three concrete axes: performance, licensing, and feature fit. Treat each axis as a weighted input to a decision matrix rather than a binary pass/fail.

  • Performance (what to measure)

    • Startup time / first-frame — measures seek/start latency.
    • Sustained CPU% & per-frame latency — drives battery and thermal behavior.
    • Memory footprint — surface allocation, buffers, and retained decoded frames.
    • Stall/jank rate (frames dropped) — worst UX indicator.
      Measure these with Android Studio Profiler or perfetto/simpleperf on Android and Instruments (xctrace) on iOS.
  • Licensing (real concerns)

    • Permissive licenses (Apache 2.0, MIT, BSD) let you ship without viral obligations. ExoPlayer uses Apache 2.0.
    • Weak copyleft (LGPL) can be workable if you use dynamic linking and don’t ship modified library code; strong copyleft (GPL) will force broader distribution of source if you distribute modified code. FFmpeg contains components under both LGPL and GPL — you must review the FFmpeg build/configuration you use.
  • Feature fit (must-haves vs nice-to-haves)

    • DRM / Widevine / FairPlay, adaptive streaming (DASH/HLS/CMAF), subtitle formats, frame-accurate editing, color management, and server-side vs on-device transcoding. If you need on-device editing or non-destructive filter graphs, FFmpeg-level APIs or native codecs are often required.

Comparison table — high-level trade-offs

SDK / API Typical performance profile Licensing profile Core use cases Typical integration effort
FFmpeg mobile Flexible CPU-bound processing; software decoders high-cost unless hw accel available Mixed LGPL/GPL — build determines obligations. Review the legal page. Transcoding, frame processing, filters, batch export, complex transforms High (native builds, per-ABI, JNI glue)
ExoPlayer Streaming-optimized; delegates decode to MediaCodec (hardware paths) Apache 2.0 (permissive). Adaptive streaming, DRM, robust playback Medium (Gradle + feature modules)
MediaCodec (Android) Lowest-level, hardware-accelerated decode/encode when available Platform API (no external license) — you must handle compatibility Minimal-footprint playback, custom renderers, low-level streaming High (device quirks, codec discovery)
Commercial SDKs Vendor-optimized, often performant across devices Proprietary licenses; SLAs available Fast delivery of DRM+analytics+consistency Low-to-medium (SDK integration)

FFmpeg mobile, ExoPlayer, and MediaCodec — where each tool genuinely earns its keep

You must treat each option as an engineering archetype, not a product name.

  • FFmpeg mobile (the swiss-army knife)
    Use FFmpeg when you need on-device format transforms, filter graphs, muxing/packaging, precise export quality control, or when the workflow is editing/transcoding-centric rather than playback-centric. The downside: software codecs are CPU-heavy on mobile; hardware-accel support depends on the build and platform. FFmpeg’s license mix (LGPL vs GPL) is build-dependent — check the chosen binary and how you link it before shipping.
    Practical integration patterns:

    • Use wrappers like ffmpeg-kit for Android/iOS to avoid writing JNI glue from scratch.
    • Optionally offload heavy transcode to a server if you can avoid per-device CPU cost.
      Example software transcode command (baseline):
  ffmpeg -i input.mov -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k output.mp4

Hardware acceleration flags and encoder names vary by platform and build; the -hwaccel/-c:v flags are platform-specific and must be validated per build.

  • ExoPlayer (streaming-first, pragmatic)
    ExoPlayer is the right starting point when streaming adaptive content (DASH/HLS) is your primary need. It handles manifest parsing, adaptive bitrate logic, track selection, buffering heuristics, and DRM plumbing — while delegating decode to MediaCodec for hardware acceleration when available. ExoPlayer’s Apache 2.0 license keeps legal churn low.
    Minimal ExoPlayer usage (pseudocode):
  val player = ExoPlayer.Builder(context).build()
  val mediaItem = MediaItem.fromUri(uri)
  player.setMediaItem(mediaItem)
  player.prepare()
  player.play()

ExoPlayer reduces implementation friction for streaming features most product teams need.

  • MediaCodec (platform API: control without dependency weight)
    MediaCodec exposes the platform’s hardware encoders/decoders. It’s the smallest binary footprint because you use system codecs, but you pay in engineering: you must detect codec availability, handle color-space and buffer-queue quirks, and implement fallback strategies when hardware decoders are absent or buggy. Use MediaCodec when you need minimal dependencies and maximal control (e.g., custom render pipelines or realtime low-latency streams).
    Minimal decoder configuration (Java):
  MediaFormat format = MediaFormat.createVideoFormat("video/avc", width, height);
  MediaCodec decoder = MediaCodec.createDecoderByType("video/avc");
  decoder.configure(format, surface, null, 0);
  decoder.start();

On iOS the equivalent low-level APIs are VideoToolbox / AVFoundation for hardware-accelerated encode/decode.

Contrarian insight: don’t pick FFmpeg purely because it “does everything.” If you’re shipping streaming playback with DRM and variable network, ExoPlayer + MediaCodec (or a commercial SDK) avoids a large maintenance surface and often gives better battery characteristics.

Commercial SDKs and when enterprise support actually repays the cost

Commercial players (Bitmovin, THEOplayer, JW Player, others) package engineering-heavy features: consistent cross-device behavior, managed DRM integrations, analytics, ABR heuristics tuned across device fleets, and enterprise SLAs. For companies with scale or strict uptime/legal requirements, that support can save hundreds of engineering hours per quarter.

What you get with a commercial SDK:

  • Vendor-maintained native binaries tuned for device families and OS versions.
  • Built-in analytics and quality metrics that tie into product dashboards.
  • Faster time-to-market for complex features (SCTE markers, low-latency streaming, DRM edge cases).
    What you lose: vendor lock-in, ongoing licensing costs, and less internal visibility into implementation details.

When enterprise support pays: if your product requires 24/7 availability, legal indemnity in contracts, or you cannot absorb a multi-quarter engineering sprint for cross-device tuning, a commercial SDK is often worth the line-item cost. If your app is an editor or you need low-level frame control, open-source/native stacks remain the better match.

Integration realities: maintenance, ABI churn, and the binary-size tax

Integration is where the project schedule usually slips. Here are the practical realities you’ll hit.

  • Binary size and ABIs

    • Bundling native libffmpeg.so for each ABI can add tens of megabytes unless you aggressively trim features. Use ABI splits, Play Feature Delivery, and on-demand modules to limit install-time impact. Android guidance on size-reducing techniques is a must-read.
    • ExoPlayer (Java/Kotlin) increases APK size more modestly because it relies on platform codecs; commercial SDKs may provide optimized native libraries with smaller per-device overhead.
  • CI and native builds

    • If you build FFmpeg yourself, you need per-ABI CI pipelines, reproducible toolchains, and security patch processes. Plan for quarterly native-library updates.
    • ExoPlayer updates are typically a Gradle dependency bump, but major releases may require API changes.
  • Compatibility matrix & device bugs

    • MediaCodec behavior varies between SoCs and Android releases (surface-hand-off, color spaces, profile support). Maintain a small compatibility matrix and automated playback tests across a representative device fleet.
  • Encapsulation pattern

    • Create a VideoPlayer interface and isolate SDK-specific implementations behind it. That enables A/B testing and a staged rollout.

Example interface (Kotlin):

interface VideoPlayer {
  fun init(surface: Surface)
  fun load(uri: Uri)
  fun play()
  fun pause()
  fun seekTo(ms: Long)
  fun release()
}

Engineering rule of thumb: If you cannot ship without a particular codec/feature on-device, assume you will need a native binary and budget maintenance for it.

Practical application: migration checklist and benchmarking protocol

This is a surgical checklist you can use when evaluating or migrating a mobile video path.

  1. Inventory the product requirements (explicit)

    • List codecs, container formats, DRM types, subtitle formats, must-have resolutions, and expected concurrency/per-session bandwidth.
  2. Baseline measurement (before any change)

    • Capture these metrics on representative devices: startup time, first frame, sustained CPU%, memory, frames dropped per 10 minutes, and battery delta over a scripted playback. Use Android Studio Profiler, perfetto, and dumpsys on Android; use Instruments and xctrace on iOS.
  3. Legal & licensing checklist

    • For each third-party component (FFmpeg builds, ExoPlayer, commercial SDK), document the license and whether you link statically or dynamically. If using FFmpeg binaries, capture the exact configure flags used to build them and run a legal review.
  4. Prototype minimally for the candidate SDKs

    • Implement a thin wrapper around playback that emits the same telemetry for all candidates.
  5. Run controlled A/B benchmarks (scripted)

    • Scripted test (Android example):
   # measure first-frame time and CPU load
   adb shell am start -W -n com.example/.PlayerActivity
   adb shell dumpsys gfxinfo com.example > gfx.txt
   adb shell top -b -n 10 -o CPU -p $(pidof com.example) > cpu-sample.txt
  • For CPU sampling, use simpleperf. For traces, use perfetto to get event-level visibility.
  1. Acceptance criteria (example)

    • First frame within X ms of baseline (or better), sustained CPU < baseline + 10%, frames dropped < 1% for typical streams, binary size delta within budget (e.g., < 10 MB per ABI), licensing approved by legal.
  2. Rollout and monitoring

    • Use feature flags and staged rollout (10% → 50% → 100%). Instrument playback metrics and collect crash/ANR telemetry.

Repeatable benchmarking protocol (table)

Metric How to collect Sample size Acceptance delta
First-frame latency adb shell am start -W / app metric 30 runs per device ≤ baseline + 200 ms
Sustained CPU simpleperf or profiler 3 x 60s runs ≤ baseline + 10%
Frames dropped dumpsys gfxinfo / player telemetry 5 x 10 min ≤ 1%
Memory Android Profiler / Instruments continuous trace No leaks; < budget

Small script snippet to capture a perf trace (Android perfetto):

adb shell perfetto -o /data/misc/perfetto-traces/trace.pb -c - <<EOF
buffers { size_kb: 4096 }
duration_ms: 10000
data_sources { config { name: "linux.ftrace" } }
EOF
adb pull /data/misc/perfetto-traces/trace.pb .

Checklist for migration decisions

  • Are the required codecs available via platform MediaCodec across your target fleet? If yes, prefer platform decoders for playback.
  • Do you need frame-accurate editing or complex filters on device? If yes, include FFmpeg or a native pipeline.
  • Do you need DRM and robust streaming with minimal engineering time? ExoPlayer or a commercial SDK will be faster.
  • Can your legal process accept the license implications of a static native binary? If not, plan server-side processing or a different SDK.

Quick example decision matrix (one line): If you ship streaming video with DRM and you care about battery and development velocity → ExoPlayer; if you ship an on-device editor with export presets → FFmpeg mobile (or ffmpeg-kit); if you need 24/7 enterprise SLAs and analytics → commercial SDK.

Sources:
FFmpeg: Legal considerations – Details on FFmpeg licensing choices (LGPL vs GPL) and how builds affect obligations.

GNU Lesser General Public License v3 (LGPLv3) – Explanation of weak copyleft and linking considerations.

ExoPlayer (GitHub) – ExoPlayer project, license (Apache 2.0), and feature set.

Android MediaCodec guide – Platform documentation for MediaCodec and hardware decoding/encoding.

ExoPlayer official site – Architectural overview and streaming/DRM features.

Reduce APK size – Android developers – Strategies for ABI splits, dynamic delivery, and binary-size reduction.

FFmpegKit (FFmpeg mobile wrapper) – Common approach for integrating FFmpeg on Android and iOS.

Android Studio profiler & performance tools – Tools and workflows for measuring CPU, memory, and rendering.

AVFoundation (Apple Developer) – iOS media APIs and hardware-accelerated encode/decode guidance.

Apache License 2.0 – License text referenced for permissive licensing.

Bitmovin Native Player docs – Example commercial SDK feature set and enterprise offerings.

Measure what matters, instrument aggressively, and treat the player as core infrastructure — the right choice is the one that aligns with your product constraints and the engineering bandwidth to support it.