RSS aggregator JS Ecosystem News
Live feed0 visits
1 today 12 unread
Lead story dev.to 2 hours ago Fresh today Unread

I built a serverless URL shortener for $4.68/year (total)

I wanted two things: short links under my own brand (every link I share points traffic back to my site), and a real excuse to run a complete system on the edge — DNS, distributed compute, storage, auth and a dashboard — in production, at zero infrastructure cost.
The result is flino.link: a shortener that responds in under 10 ms from 300+ locations, runs entirely on Cloudflare's free tier, and whose only expense is the domain: $4.68 a year. This post covers the design decisions, which is where the interesting parts are.
The architecture in 30 seconds
A single Cloudflare Worker serves the whole domain:
GET /<slug> — the hot path. One read from Workers KV (globally replicated) and a 302 redirect. Nothing else touches that path.
/api/links — a REST API with Bearer auth to create, list and delete links.
/admin — a single-page dashboard served as inline HTML from the Worker itself. No framework, no build step.
A Durable Object with embedded SQLite keeps per-slug click counts.

No servers, no containers, no database to manage. The whole Worker is three TypeScript files and zero runtime dependencies.
Why a dedicated domain?
My first idea was to hang the shortener off a route on flino.dev. Bad idea: shorteners attract abuse — spam, phishing — and their domains sooner or later end up on blocklists. If that happens, I don't want it dragging my main domain down with it. A separate domain isolates that reputation risk completely, and a short .link costs less than a coffee per year.
KV for links, a Durable Object for counters
This is the central design decision. Workers KV is perfect for a shortener's access pattern — read-heavy, write-light, reads served from the edge — but its writes are eventually consistent: two concurrent increments in different datacenters would clobber each other. For counting clicks, it's useless.
A Durable Object solves exactly that: it's a single global instance with transactional SQLite storage. Every increment, no matter which datacenter it comes from, serializes through one consistent point:

export class ClickCounter extends DurableObject<unknown> { private sql = this.ctx.storage.sql; increment(slug: string): void { const now = Date.now(); this.sql.exec( "INSERT INTO clicks (slug, count, last_click) VALUES (?, 1, ?) " + "ON CONFLICT(slug) DO UPDATE SET count = count + 1, last_click = ?", slug, now, now, ); } }
Doesn't that single point become a bottleneck for redirects? No — because of what comes next.
The waitUntil trick: click counting with zero latency
A shortener's contract is to redirect fast. If the redirect had to wait for the Durable Object write, every click would pay an extra round-trip. The solution is ctx.waitUntil(): it hands the runtime a promise that executes after the response has been sent to the visitor.

const target = await env.LINKS.get(slug); if (target !== null) { // Runs after the response is sent — adds no latency to the redirect. ctx.waitUntil(counter(env).increment(slug)); return Response.redirect(target, 302); }
The visitor gets their 302 after a single KV read; the counting happens in the background. Free analytics, in the literal sense of the word.
Fail toward the brand
What happens when someone visits a slug that doesn't exist, or the domain root? Never a 404: always a redirect to flino.dev. A broken or deleted link never shows an ugly error page — it shows my site. Every dead path in the system becomes a touchpoint.

// Unknown slug, root, or anything else: return Response.redirect("https://flino.dev", 302);
The dashboard: inline HTML, no framework
The admin panel is a TypeScript constant holding a complete HTML page that the Worker serves at /admin. The API key lives in localStorage, dark mode comes free with prefers-color-scheme, and creating a link auto-copies the short URL to the clipboard. Zero dependencies, zero build, and it deploys together with the Worker in the same wrangler deploy.
For a project this size, a frontend framework would have been more infrastructure than product.
The numbers
Redirect latency < 10 ms from 300+ locations Capacity (free tier) ~100,000 requests/day Infrastructure cost $0/mo Total cost $4.68/yr (the domain) Runtime dependencies 0 What I'm taking away
The edge changes the defaults. For a read-heavy service, the question is no longer "how close do I put the server?" but "why would there be a server?".
Pick storage by semantics, not by habit. KV and Durable Objects coexist in the same Worker, each doing the one thing it's good at: global replication for reads, strong consistency for counting.
waitUntil is the most underrated pattern in Workers. Anything the visitor doesn't need — metrics, logs, counters — can move off the critical path.

The full code is on GitHub, and if you want to see the system in action, this link goes through it: flino.link/devto-en. (Yes — your click is already on my dashboard 😄)
Where would you have hosted this? Workers, a $5 VPS, Deno Deploy, fly.io? I'd genuinely like to read other approaches in the comments. 👇
Jul 23, 2026, 12:10 AMSignal 3
Open article
dev.to3 hours ago
Unread

There's a moment, building an autonomous agent, when you realize you have no idea what it's doing. Not in t...

Open
dev.to4 hours ago
Unread

About the project We're developing a modern, enterprise-grade application using the latest web technologies...

Open
dev.to5 hours ago
Unread

As I've been working more with Web3 technologies, I've found myself at a crossroads when it comes to alloca...

Open

Live feed

Latest ranked updates

1 / 9
dev.to 8 hours ago Unread
It works on your laptop. You wrote fifteen lines of Puppeteer, pointed it at a
URL, got a PNG back. You wrapped it in a route, deployed it, and for about a day
it was the easiest feature you ever shipped. Then the container got OOM-killed
at 2am, the queue backed up behind it, and you spent the next week learning that
"just screenshot the page" is a systems problem wearing a fifteen-line disguise.
Here is the list of what breaks, in roughly the order it breaks, and the process
shape that stops it. It's the same list whether you're on Puppeteer or
Playwright, and whether you're rendering screenshots, PDFs, or OG images — it's
Chrome's lifecycle that's hard, not the API in front of it.
1. Memory: the first wall, and the loudest
A single headless Chrome rendering one page sits in the low hundreds of
megabytes of RSS — call it 150–300 MB depending on the page. That's per render,
and it's the good case. Chrome also leaks: keep one instance alive long enough
and RSS climbs and doesn't come back, because a browser was built to be closed
by a human at the end of the day, not kept resident for a month. On a box with a
fixed memory limit the ending is always the same — the kernel's OOM killer picks
the fattest process and kills it mid-render, and your logs show a bare SIGKILL
with no stack trace.
If you've searched puppeteer out of memory and found forty issues with no
accepted answer, this is why: there isn't a line to fix, there's a lifecycle to
manage. Two things follow. Cap how many renders share one instance and
recycle it — close the whole browser and launch a fresh one every N pages,
so leaked memory is reclaimed by process death, not by hope. And size
concurrency to RAM, not CPU: if one render is 250 MB, eight concurrent
renders is 2 GB before you've counted the OS, and "eight" is a small number.
2. Zombies: the processes that don't die
browser.close() is supposed to clean up, and usually does. But Chrome isn't
one process — it's a tree: a main process, a zygote, a renderer per page, plus
GPU and utility helpers. When Chrome crashes, or your Node process is killed
while a browser is open, or a navigation wedges the renderer, close() never
runs or never finishes, and you're left with <defunct> chrome processes
reparented to init. They hold memory and file descriptors. Do that a few
thousand times and you exhaust PIDs or FDs, and the box stops accepting work for
reasons that have nothing to do with your code.
The fix is unglamorous: reap the tree yourself. Track the browser's PID and, on
any abnormal exit, kill the whole process group instead of trusting the
library's close(). In a container, run a real init that reaps orphans
(--init, tini, or dumb-init) so PID 1 isn't your app pretending to be an init
system it isn't.
3. Cold starts: the 800ms tax
Launching Chrome costs roughly 800 milliseconds before it renders a single pixel
— process spawn, sandbox setup, the first blank page. Launch a fresh browser per
request and you pay that tax every request, where it dwarfs the actual render for
anything simple. So the instinct is to launch one browser and reuse it forever —
which walks you straight back into problem #1.
The resolution is the distinction most tutorials skip: reuse the instance,
isolate per context. A browser context is a clean, cookieless, cacheless
session inside an already-running Chrome — cheap to create, cheap to destroy,
isolated from every other render.

// not this — 800ms of startup tax on every request const browser = await puppeteer.launch() const page = await browser.newPage() // this — warm instance, throwaway context per render const context = await browser.createBrowserContext() // cheap, isolated const page = await context.newPage() // …render… await context.close() // nothing leaks into the next render
Keep a small pool of warm instances, give each render its own fresh context,
throw the context away after, and recycle the whole instance every N renders
(problem #1). You pay the 800ms once per instance lifetime, not once per request,
and renders don't bleed into each other.
4. Pages that fight back
Your renderer navigates to a URL and waits. Sometimes the wait never ends: an
infinite redirect, a page that never fires load, a websocket that keeps the
network "busy" forever, a while(true) in someone's analytics. Without a hard
ceiling, one bad URL parks a browser until it's killed — and if you're reusing
instances, one hostile page can wedge a slot in your pool for good.
Put hard timeouts on both navigation and capture, and when one trips, kill the
instance and launch a new one — don't try to nurse it back.
A browser that
hung once is not a browser you can reason about; it's carrying whatever state
caused the hang. The correct response to a sick Chrome is a fresh Chrome. It
feels wasteful and it's the single most reliability-improving rule in the whole
system.
5. Concurrency is a queue problem, not a loop
The naive version renders inline: request arrives, you launch or borrow a
browser, render, respond. Under load this is exactly how you die — a traffic
spike becomes N simultaneous browsers becomes an OOM kill becomes every in-flight
render failing at once. Rendering is expensive and bursty, which is the precise
profile a queue exists for.
Put a queue between the request and the render. The API accepts the job and
returns immediately; a pool of workers pulls jobs at a rate their RAM can
survive. A spike becomes queue depth — a number you can watch and autoscale
on — instead of a memory graph that falls off a cliff. Queue depth is your
capacity early-warning signal; RSS-at-the-OOM-line is the alternative, and it
warns you by paging you.
6. The font stack nobody mentions
Your laptop has fonts. A minimal Linux container does not. So the page that
looked right locally renders with tofu boxes where the CJK text was, blank
rectangles where the emoji were, and the wrong fallback for everything else — and
you find out from a customer's screenshot, not a test. Real page rendering means
you now own a font pipeline: a base font set, CJK coverage, an emoji font, and
the standing knowledge that a Chrome upgrade can shift glyph rendering and
quietly change your output out from under a cache.
7. Serverless doesn't make this go away
The reflex is "put it on Lambda and let someone else scale it." That moves the
problems, it doesn't remove them. You ship a special slimmed Chromium build to
fit the unzipped size limit; you eat a multi-second cold start on every
scale-up, because a warm pool is exactly what serverless won't give you; you cap
out at the function's memory and time limits, which is where playwright lambda
timeout
comes from; and you still own the fonts. It's a legitimate deployment
target, but it's a different set of sharp edges, not fewer of them.
The shape that survives
Put it together and the design that works isn't exotic — it's this list, applied
consistently:
![Architecture diagram: bursty traffic enters a queue; a pool of stateless, disposable workers each run a warm Chrome instance with one context per render, recycled every N renders and killed on hang; results land in an edge cache served in milliseconds.]

A queue between the request and the render — capacity is a worker count, not a prayer.
Stateless, disposable workers, each holding a small pool of warm Chrome instances.
A fresh context per render, discarded after.
Recycle each instance every N renders, so leaks die with the process.
Hard timeouts on navigation and capture; kill-and-respawn on any hang — never nurse a sick browser.
Concurrency sized to RAM, with queue depth as the capacity signal.
A maintained font and emoji stack, plus a cache version you can bump when Chrome moves.

That's the whole trick. It's also, to be blunt, most of what our workers do —
because there isn't a cleverer answer, only this list, monitored.
Disclosure before the turn: we run this as a service
(Shotpipe), so read the next two sections as the author
pointing at their own tool. The list above is true whether you build it or buy
it — I'm claiming the list is the work, not that you need us to do it.

The part the memory threads leave out
Every puppeteer out of memory thread is about pages you control — your own
dashboard, your own invoice, your own marketing page. The moment the URL comes
from your users — a link preview, an unfurl, a "screenshot my site" button —
you've added a second problem that has nothing to do with memory: the URL
might point back at you.
Someone submits
http://169.254.169.254/latest/meta-data/ and your obliging headless browser
reads your cloud credentials and hands them back as a PNG. That's SSRF, and a
browser is a near-perfect engine for it, because it follows redirects and
resolves DNS for you — the two exact places the attack hides.
Fixing it properly means resolving DNS yourself, checking every resolved IP
against private, loopback, link-local, and cloud-metadata ranges, pinning the IP
you validated and connecting to that one, and re-checking on every redirect
hop. It's a module with its own test suite, not an if-statement — and it's the
part no memory-leak tutorial mentions, because those authors are rendering their
own pages. We wrote it up in screenshotting URLs you don't
control
: if your renderer ever touches a
URL a stranger typed, read that before you ship.
When to run it yourself anyway
Honestly: if you render a handful of pages you control, on a schedule,
self-hosting is fine. Launch Chrome, render, close, move on — none of the above
bites at that volume, and you shouldn't pay anyone to avoid a problem you don't
have. The list starts mattering when renders get frequent, bursty, or pointed at
URLs you don't own. That's the crossover where "just screenshot the page" stops
being fifteen lines and becomes a service — ours, or the one you'll end up
building.
Open 3 pts · Jul 22, 2026, 6:32 PM
github.blog 10 hours ago Unread
GitHub is making some significant changes to its bug bounty program, shifting its focus to give researchers a better experience working with the GitHub team.
The post Next chapter: Restructuring GitHub’s bug bounty program appeared first on The GitHub Blog.
Open 4 pts · Jul 22, 2026, 4:00 PM
dev.to 11 hours ago Unread
Autonomous Development: Gusto API Wrapper for Simplified Employee Data Sync
Integrating with third-party APIs can often be a complex and time-consuming task, especially when dealing with critical HR and payroll data like that managed by Gusto. Developers often face challenges with authentication, rate limits, data mapping, and maintaining robust connections. At Pixel Office, we're continuously exploring how AI agents can streamline such development processes, and our latest project demonstrates this perfectly: an autonomously generated Gusto API Wrapper for simplified employee data synchronization.
The Challenge: Bridging Gusto's API Complexity
Gusto's API offers comprehensive functionalities, but building a direct integration from scratch requires significant effort to handle various endpoints, data structures, and best practices for secure and efficient data exchange. Our goal was to create a utility that abstracts away this complexity, providing a simplified interface for developers to sync employee data, all while leveraging our internal AI development pipeline.
Our AI Team in Action: Jan, Klára, Martin, and Tomáš
This project was a testament to the collaborative power of our AI agents:
Jan (AI Developer): Took the lead in understanding the Gusto API documentation, designing the wrapper's logic, and writing the core JavaScript code. Jan focused on creating a modular, robust, and easy-to-use API client.
Klára (AI Designer & Architect): Collaborated with Jan on the architectural design, ensuring the wrapper was scalable, secure, and followed best practices for API integration, including error handling and authentication flows.
Martin (AI QA Engineer): Thoroughly tested the generated wrapper, validating its functionality against various scenarios, ensuring data integrity, and identifying potential edge cases.
Tomáš (AI Deployment Specialist): Handled the deployment pipeline, integrating the wrapper into our existing infrastructure and ensuring it was ready for production use, complete with monitoring and logging.

Technical Deep Dive: Inside the Gusto API Wrapper
The core of the wrapper is a JavaScript module designed to encapsulate Gusto API calls, providing helper functions for common operations like fetching employee lists, updating profiles, or managing payroll details. It leverages a secure authentication mechanism, often involving OAuth or API keys, and handles data serialization/deserialization.
Here’s a snippet showcasing the foundational setup, including our custom configuration and Firebase integration for authentication, demonstrating how the wrapper is initialized:

// Widget ID for localStorage and API calls const WIDGET_ID = "gusto-api-wrapper-for-simplified-employee-data-sync"; const WHATSAPP_NUMBER = "420607450436"; const API_BASE_URL = "https://api.pixeloffice.eu/api/pay"; const PIXEL_OFFICE_URL = "https://pixeloffice.eu"; // Firebase Configuration (provided in requirements) const firebaseConfig = { apiKey: "AIzaSyFakeKeyForShowcaseHubAuthTestingOnly", authDomain: "pixeloffice-hub.firebaseapp.com", projectId: "pixeloffice-hub", storageBucket: "pixeloffice-hub.appspot.com", messagingSenderId: "1234567890", appId: "1:1234567890:web:abcdef123456" }; // Initialize Firebase if not already initialized if (!firebase.apps.length) { firebase.initializeApp(firebaseConfig); } const auth = firebase.auth(); // Global i18n dictionary const i18n = { // ... a další multijazyčné překlady
"My primary focus was to ensure the wrapper was not just functional, but also highly intuitive for developers. I abstracted away the complex OAuth flow and error handling into simple, callable methods, so developers can concentrate on their business logic rather than API quirks. The WIDGET_ID and API_BASE_URL are key for modularity and environment configuration." – Jan (AI Developer)

The wrapper provides methods like getEmployees(), updateEmployee(id, data), and createPayroll(data), each internally handling the HTTP requests, error responses, and data formatting required by Gusto. This significantly reduces boilerplate code and potential errors for integrators.
See it in Action!
We believe in practical demonstrations. You can explore the live demo of our Gusto API Wrapper and see how it simplifies employee data synchronization. Experiment with its capabilities and imagine how it could accelerate your HR-related development projects.
Live Demo: https://pixeloffice.eu/showcase/gusto-api-wrapper-for-simplified-employee-data-sync/
Conclusion
This project underscores the immense potential of AI in autonomous software development. By delegating complex API integration tasks to agents like Jan and Klára, we can rapidly prototype and deploy robust solutions, freeing human developers to focus on higher-level innovation. We invite you to explore the wrapper, integrate it into your projects, and share your feedback!
Open 3 pts · Jul 22, 2026, 3:30 PM
dev.to 13 hours ago Unread
Removing blank pages from a PDF sounds trivial — until you realize that "blank" can mean many things. Is a page with a faint gray background blank? What about a page that has only a header or footer?
Here's how I built a browser-based blank page detector using PDF.js rendering, canvas pixel analysis, and pdf-lib for the actual removal.
Why client-side?
Server-side tools require uploading your file first. For documents with sensitive content, that's a risk. A browser-based approach:
Processes everything locally
Shows you a preview before deleting
Works offline after loading
Respects user privacy by design

The stack
Vue 3 + Composition API
PDF.js (pdfjs-dist) for page rendering
html2canvas-style pixel analysis
pdf-lib for PDF manipulation
Vite for bundling

The core algorithm
<script setup lang="ts"> import { ref } from 'vue' import * as pdfjs from 'pdfjs-dist' import { PDFDocument } from 'pdf-lib' pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.min.js' interface BlankPageResult { pageIndex: number isBlank: boolean confidence: number // 0-1, higher means more confident it's blank } async function detectBlankPages( file: File, sensitivity: 'low' | 'medium' | 'high' = 'medium' ): Promise<BlankPageResult[]> { const arrayBuffer = await file.arrayBuffer() const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise const thresholds = { low: 0.01, medium: 0.05, high: 0.1 } // ratio of non-white pixels const results: BlankPageResult[] = [] for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i) const viewport = page.getViewport({ scale: 1 }) // Create offscreen canvas to render and analyze const canvas = document.createElement('canvas') canvas.width = viewport.width canvas.height = viewport.height const ctx = canvas.getContext('2d')! await page.render({ canvasContext: ctx, viewport }).promise const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height) const totalPixels = imageData.data.length / 4 let nonWhitePixels = 0 // Analyze pixel by pixel for (let p = 0; p < imageData.data.length; p += 4) { const r = imageData.data[p] const g = imageData.data[p + 1] const b = imageData.data[p + 2] // A pixel is considered "white" if all channels are > 240 if (r < 240 || g < 240 || b < 240) { nonWhitePixels++ } } const whiteRatio = nonWhitePixels / totalPixels const confidence = Math.min(1, thresholds[sensitivity] * 5) const isBlank = whiteRatio < thresholds[sensitivity] results.push({ pageIndex: i - 1, // zero-indexed for pdf-lib isBlank, confidence: 1 - whiteRatio // higher = more content }) } return results } </script>
Removing the detected blank pages
Once we know which pages are blank, use pdf-lib to remove them:

async function removeBlankPages( arrayBuffer: ArrayBuffer, blankIndices: number[] ): Promise<Uint8Array> { const pdfDoc = await PDFDocument.load(arrayBuffer) const pageCount = pdfDoc.getPageCount() // Sort indices in descending order to avoid shifting problems const sorted = [...blankIndices].sort((a, b) => b - a) for (const index of sorted) { if (index >= 0 && index < pageCount) { pdfDoc.removePage(index) } } return await pdfDoc.save() }
Key detail: sorting in descending order. If you remove page 5 first, page 6 becomes page 5. By removing from highest index to lowest, positions stay stable.
Visual feedback
Users need to see which pages will be deleted before committing:

async function generateThumbnails(file: File, count: number): Promise<string[]> { const arrayBuffer = await file.arrayBuffer() const pdf = await pdfjs.getDocument({ data: arrayBuffer }).promise const thumbnails: string[] = [] for (let i = 1; i <= Math.min(pdf.numPages, count); i++) { const page = await pdf.getPage(i) const viewport = page.getViewport({ scale: 0.3 }) // smaller thumbnail const canvas = document.createElement('canvas') canvas.width = viewport.width canvas.height = viewport.height const ctx = canvas.getContext('2d')! await page.render({ canvasContext: ctx, viewport }).promise thumbnails.push(canvas.toDataURL('image/jpeg', 0.7)) } return thumbnails }
Each thumbnail gets a badge: "blank" (red), "likely blank" (yellow), or "has content" (green). Users can override by clicking any page.
Performance considerations
For large PDFs (100+ pages), pixel-by-pixel analysis gets slow. Two optimizations:
Downscale before analyzing. Don't use full-resolution pages. Scale down to 200px width. Blank detection doesn't need megapixel precision.
Process pages in parallel using Web Workers. Each worker handles a chunk of pages.

// Downscale example const SCALE = 0.15 // 15% of original resolution const viewport = page.getViewport({ scale: SCALE })
At 15% scale, a 3000px-wide page becomes 450px. That's 12x fewer pixels to check per page, with negligible impact on accuracy for blank-page detection.
UX tips from a live tool
At en.sotool.top/remove-blank-pages, we learned:
Show confidence scores, not binary decisions. "This page is 92% likely blank" is more honest than "blank" or "not blank."
Let users override. Auto-detection isn't perfect. A single click to keep a flagged page builds trust.
Warn about edge cases. Pages with very light text, faint watermarks, or custom backgrounds may be misclassified.
Handle multi-size PDFs separately. Don't apply a global threshold if some pages are letter-sized and others are A4.

Going further
Blank page removal is straightforward in theory but tricky in practice because "blank" is subjective. For production use, consider combining pixel analysis with text-layer checks — a page might look blank visually but still have selectable text that matters.
Want to see the full source? github.com/sunshey/pdf-tool.
If you need desktop-grade PDF editing — batch blank page removal, OCR, or advanced export formats — check out Wondershare PDFelement.
Open 7 pts · Jul 22, 2026, 1:30 PM
dev.to 17 hours ago Unread
16 months. Several hundred hours alongside client work. One finished product.
I built LaizyNote — a business operating system for freelancers and solo entrepreneurs: notes, tasks, projects, time tracking, contacts, calendar, documents and an AI assistant in a single interface.
I've written plenty about client projects. This is different — this one is mine. And that changes everything: the perspective, the responsibility, and the honest realization of how much a "finished" SaaS actually takes.
No code in this one. Just the parts that were genuinely hard.
Why build your own SaaS?
Out of my own need. It bugged me that there was no tool you could start with for free, that had everything I needed — and that would later also help me run my business. The big names can do a lot, but they're bloated: you spend weeks setting up before you jot down your first thought.
I wanted the opposite: start instantly, yet have everything on board when you need it. While building, it became clear this isn't a niche — it's a gap. Freelancers juggle five tools (notes here, time tracking there, invoices somewhere else) and none of them shows how their business is actually doing.
That last part — Business Insights — is the one feature I'd single out. Because LaizyNote already knows your time, projects, clients and revenue, it can derive the numbers you'd otherwise keep in a spreadsheet: your real hourly rate (not the one you bill), the trend over years, client health, whether your portfolio leans too heavily on one client. Other tools show you what you did. This shows you what it earned you.
Hard part #1: keeping an overview at scale
The biggest technical challenge wasn't a single difficult feature. It was keeping an overview: where which data lives, where it flows, who is allowed to see what — and making sure it stays that way while ten other things change.
LaizyNote runs on Firebase (Firestore + server-side Cloud Functions, hosted in the EU). Every user has a personal space but can also share team workspaces with roles and permissions. That's where it gets tricky: every single query has to know whether it concerns personal or shared team data, and whether the user may access it at all.
With other people's data you can't afford a single mistake. So the access rules live server-side — the server decides what someone may see, never the browser. Everything follows the same patterns: consistent data paths, workspace-specific caches, centrally defined rules instead of special cases per feature. It sounds bureaucratic. It's the only way a new feature doesn't quietly break three old ones.
Hard part #2: why the last 10% takes months
Prototyping is incredibly fast today. A new module stands in days. What eats time is the polish — the last 10% that separates a hobby project from a product people trust with their work.
That 10% is made of things nobody notices as long as they work, and that stand out immediately when they don't:
What happens with empty data, on the first login, with 10,000 entries?
A link that should open the right plan straight after sign-up — and quietly fails if a single parameter is missing.
A bonus offer that accidentally locks out everyone who ever deleted an account.
A CSS rule that silently shifts the entire app by 16 pixels.
Six languages that have to be maintained with every new line of text.

None of these is big on its own. But there are hundreds of them, and each one wants to be found, understood and cleanly solved.
The lesson that stuck: passing tests don't prove it works in the real app. Some bugs only show up when you operate it yourself in the browser. So a lot gets tested by hand — especially anything involving real money or other people's data.
Hard part #3: building AI into a product
Daisy is the AI assistant in LaizyNote. You write to her in plain language and she can act: create tasks, structure projects, link contacts, summarize time and numbers. "Create a project Website with three tasks for Emma" becomes exactly those entries.
The crucial part: she asks before every write action. She shows a preview — "5 tasks will be created" — and only a confirm click executes it. AI that writes into your data on its own, without you watching, would be a breach of trust.
Building AI into a real product brings a challenge classic development doesn't have: it's not predictable. The same question can be answered slightly differently twice. The model doesn't always stick to the required format. Answers sometimes run too long mid-sentence. None of this is checkable with "is the code correct" — it has to be tried by hand, over and over. Two constraints mattered most: privacy (Daisy runs through a European AI provider, EU-hosted — fitting for a .eu product) and cost control (usage quotas and a fixed monthly cap per user, so neither the user nor I get a nasty surprise).
The part I underestimated
The biggest surprise wasn't the technology. It was how much is needed around the product before you can bill a single customer.
A SaaS isn't just an app. It's a brand with a name, logo and voice. Legal texts, terms, a privacy policy, a service description that holds up contractually. Payment processing with subscriptions, cancellations, yearly and monthly prices, vouchers and invoices. Automated emails, backups, and processes for when someone deletes their account. Users never see most of it — but without that foundation there's no product you can trust.
I thought building the product was the work. In truth the product is one half; the other is brand, law, payment and operations.
Would I do it again?
Yes — much of it exactly the same. Firebase was the right call for a one-person project with a large feature set: it takes backup, scaling and auth off your plate so you can focus on the product. What I'd change is the planning — I'd cut some data structures differently from the start (things you only understand once you've built them wrong), and I'd budget for all the surrounding work earlier instead of treating it as "I'll do that at the end".
The result is live and publicly available, with all modules in use. Anyone can start for free — which was the whole point from the beginning.
Takeaways if you're building your own SaaS:
Prototyping is fast; the polish is the real work — plan for it.
With other people's data, the server decides, never the browser.
AI is unpredictable — it needs manual testing and clear, honest limits.
A SaaS is half product, half brand/law/operations.
Green tests are no proof. When in doubt, use it yourself.

Full write-up (and the product) on hafenpixel.de. You can try LaizyNote for free — no credit card. Happy to answer questions in the comments.
Open 3 pts · Jul 22, 2026, 9:18 AM
dev.to yesterday Unread
Nuxt vs SvelteKit. Which one is better?
That is is what I've been testing out this week. I built the same app twice. Once in Nuxt with the version 5 compatibility preview turned on, and once in SvelteKit using its experimental remote functions.
I created a basic task app. Where you can add or remove tasks. I also logged the network requests and timing. The biggest thing I noticed was that the SvelteKit app would make one request, when creating a new task, while the Nuxt app needed two.
That one difference turned out to be the most interesting part of the whole comparison, so let's jump in.
The setup
Both apps are a simple task list backed by an in browser memory store. Both are production builds running locally, and both render the initial task list on the server.
Quick caveat, Nuxt 5 is not released yet. My Nuxt app is stable Nuxt 4.5 with the compatibility flag set:

// nuxt.config.ts export default defineNuxtConfig({ compatibilityDate: '2026-07-01', future: { compatibilityVersion: 5, }, })
And SvelteKit's remote functions are still marked experimental in the docs. So this is a comparison of directions, not finished products.
The numbers
When I add a task in the Nuxt app, I get a POST to /api/tasks (about 690 ms) followed by a GET to /api/tasks (about 450 ms) to refresh the list. A little over 1,100 ms total, and the timeline panel reports two browser requests.

When I add a task in the SvelteKit app, I get one request. About 1,100 ms. The server still does both operations, the mutation and the read, but they come back in a single response.

Plain refreshes were nearly identical. 447 ms in Nuxt, 448 ms in SvelteKit. I ran this quite a few times, and if I had to pick, SvelteKit felt slightly faster overall. But the totals were close enough that I wouldn't choose a framework based on them.
Let's talk about how requests work in each.
The Nuxt version: explicit API routes
If you've used Nuxt before, this will feel familiar. I have two handlers in server/api:

// server/api/tasks.get.ts import { defineEventHandler } from 'h3' import { readTasks } from '../utils/task-store' export default defineEventHandler(() => readTasks())
The POST handler validates the title and saves the task. These are normal HTTP endpoints. Anything that speaks HTTP can call them.
On the page, useFetch loads the initial data during SSR, so hydration doesn't fetch it again. When I add a task, I post with $fetch and then call refresh():

const { data: snapshot, refresh } = await useFetch<TaskSnapshot>('/api/tasks', { key: 'task-dashboard', }) async function submitTask() { await $fetch('/api/tasks', { method: 'POST', body: { requestId: crypto.randomUUID(), title }, }) await refresh() }
The code is explicit, and the network tab matches the code exactly. POST first, GET second.
Could I avoid the second request? Sure. The POST could return the updated list and I could patch local state myself. I wrote it this way because invalidate-and-refetch is the workflow most of us reach for, and it's exactly the pattern SvelteKit's remote functions are designed to improve. I also find by adding a GET request we are verifying the exact output after the POST.
The SvelteKit version: remote functions
This is the experimental feature. You turn it on in svelte.config.js:

const config = { kit: { adapter: adapter(), experimental: { remoteFunctions: true, }, }, compilerOptions: { experimental: { async: true, }, }, }
Then you create a file ending in .remote.ts and export your server functions:

// tasks.remote.ts import { command, query } from '$app/server' import { addTask as addTaskToStore, readTasks } from '$lib/server/task-store' import * as v from 'valibot' const taskInput = v.object({ requestId: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(100)), title: v.pipe(v.string(), v.trim(), v.minLength(1), v.maxLength(80)), }) export const getTasks = query(async () => readTasks()) export const addTask = command(taskInput, async ({ requestId, title }) => { const result = await addTaskToStore(title, requestId) // This runs on the server, and the refreshed query value // comes back in the same command response. void getTasks().refresh() return result })
The function bodies always run on the server. In the browser, they become typed wrappers around endpoints SvelteKit generates for you. I really enjoy how there is no public endpoint to hit, it's isolated. It's created for the call, which means I can use environment variables and secrets in there without thinking about it.
The void getTasks().refresh() line is the API on the server side to trigger the refresh. After the mutation, SvelteKit refreshes the query on the server and packages the new value into the command's response. That's the single-flight mutation, and it's why the network tab shows one request instead of two.
On the page, I just import and call the functions:

<script lang="ts"> import { addTask, getTasks } from './tasks.remote' const tasks = getTasks() async function submitTask(event: SubmitEvent) { event.preventDefault() await addTask({ requestId: crypto.randomUUID(), title }) } </script> {@render dashboard(await tasks)}
That await tasks at the bottom works almost like a subscription. When the server-side refresh happens, the task list updates automatically. No server routes to worry about.

My first look at this pattern, I thought it was a little complicated. But it clicked pretty fast, and the query-refreshes-inside-the-command idea makes sense once you see it in the network tab. If you've used server actions in Next or TanStack Start, this will feel like family. I'd say I still like TanStack Start's server actions a bit better, but this is close.
Heads up: remote functions have been available since SvelteKit 2.27 and they are still experimental. The API has changed several times over the past few months. If you adopt them early, pin your versions and budget time for migrations.

What does Nuxt 5 offer?
Nuxt 5 doesn't have an answer to remote functions. Most of the confirmed work is in the underlying structure. A new version of Nitro, a new Vite integration, and framework internals. The upgrade guide walks through what to expect, and it's mostly foundation work rather than new application-level APIs.
My verdict
I'm sticking with Nuxt. I love the API routes pattern, I love Vue, and nothing in this demo is a reason to rewrite an existing app. You can return updated data from a mutation today and skip the second request yourself if it matters.
But I do miss server actions. Frameworks like SvelteKit, Next, and TanStack Start all have some version of a typed, non-public server function you can call from a component. I hope Nuxt adds something like it in a future update, beyond the server components they have now.
If you're starting a SvelteKit project and can live with an experimental API, try remote functions first. The single-flight mutation is awesome, and the types crossing the boundary for free is a great developer experience.
Which side are you on: explicit API routes, or remote functions that generate the transport for you? Let me know in the comments if you agree or disagree.
BTW, I used Kiro for all my research for this post and video! Check it out , it's an amazing harness!
Open 3 pts · Jul 22, 2026, 1:13 AM
github.blog yesterday Unread
Canvases turn AI into interactive workspaces where you can visualize information, explore workflows, and take action across complex tasks.
The post How to build interactive experiences with canvases appeared first on The GitHub Blog.
Open 4 pts · Jul 21, 2026, 4:00 PM

Scroll down to load more stories.