This new release brings a major upgrade to Juno Analytics β now powered by native HTTP requests with no more web workers or IndexedDB.
The JS client is now over 90% (π₯) smaller (just 3KB gzipped!), and the dashboard supports paginated views, top time zones, and OS metrics.
There are a few breaking changes (βΌοΈ), so check the notes if youβre using analytics β and make sure to upgrade your Orbiter and JS libraries at the same time β οΈ.
One of the goals with Juno has always been to make building decentralized, secure apps feel like something you're already used to. No weird mental models. No boilerplate-heavy magic. Just code that does what you expect, without touching infrastructure.
And with this release, we're taking another step in that direction:
You can now write serverless functions in TypeScript.
If you're a JavaScript developer, you can define backend behavior right inside your container. It runs in a secure, isolated environment with access to the same hooks and assertions you'd use in a typical Juno Satellite.
No need to manage infrastructure. No need to deploy a separate service. Just write a function, and Juno takes care of the rest.
Cherry on top: the structure mirrors the Rust implementation, so everything from lifecycle to data handling feels consistent. Switching between the two, or migrating later, is smooth and intuitive.
Rust is still the best choice for performance-heavy apps. That's not changing.
But let's be real: sometimes you just want to ship something quickly. Maybe it's a prototype. Maybe it's a feature you want to test in production. Or maybe you just want to stay in the JavaScript world because it's what you know best.
Now you can.
You get most of the same tools, like:
Hooks that react to document or asset events (onSetDoc, onDeleteAsset, etc.)
Assertions to validate operations (assertSetDoc, etc.)
Utility functions to handle documents, storage, and even call other canisters on ICP
The JavaScript runtime is intentionally lightweight. While it doesn't include full Node.js support, we're adding polyfills gradually based on real-world needs. Things like console.log, TextEncoder, Blob, and even Math.random β already covered.
The approach to writing serverless functions in Rust and TypeScript is aligned by design. That means if you outgrow your TS functions, migrating to Rust won't feel like starting from scratch. The APIs, structure, and flow all carry over.
Alongside TypeScript support, we've rethought the local development experience.
Instead of providing a partial local environment, the mindset shifted to mimicking production as closely as possible.
You still get a self-contained image with your Satellite, but now you also get the full Console UI included. That means you can manage and test your project locally just like you would on mainnet.
Here's the beautiful part: even though your serverless functions are written in TypeScript, they're bundled and embedded into a Satellite module that's still compiled in Rust behind the scenes.
But you don't need to install Rust. Or Cargo. Or ic-wasm. Or anything that feels complicated or overly specific.
All you need is Node.js and Docker. The container takes care of the rest: building, bundling, embedding metadata and gives you a ready-to-run Satellite that runs locally and is ready to deploy to production.
In short: just code your functions. The container does the heavy lifting.
This isnβt just a feature announcement β serverless functions in TypeScript are already live and powering real functionality.
I used them to build the ICP-to-cycles swap on cycles.watch, including all the backend logic and assertions. The whole process was documented over a few livestreams, from setup to deployment.
If you're curious, the code is on GitHub, and thereβs a playlist on YouTube if you want to follow along and see how it all came together.
We've put together docs and guides to help you get started. If you're already using the Juno CLI, you're just one juno dev eject away from writing your first function or start fresh with npm create juno@latest.
To infinite and beyond,
David
Stay connected with Juno by following us on X/Twitter.
Until now, running a local project meant spinning up an emulator with just enough to build with a single default Satellite container for your app.
That worked. But it wasnβt the full picture.
With the latest changes, local development now mirrors the production environment much more closely. You donβt just get a simplified setup β you get the actual Console UI, orchestration logic, and almost a full infrastructure that behaves like the real thing.
This shift brings something most cloud serverless platforms don't offer: production-level parity, right on your machine.
Local development isnβt just about getting things to run. Itβs about understanding how your project behaves, how it scales, and how it integrates with the platform around it.
With this shift, you build with confidence that what works locally will work in production. You donβt need to guess how things will behave once deployed β youβre already working in an environment that mirrors it closely.
It also helps you gradually get familiar with the tools that matter, like the Console UI. You learn to use the same workflows, patterns, and orchestration logic that apply when your app goes live.
This removes a lot of friction when switching environments. There's less surprise, less debugging, and a lot more flow.
Itβs local development, but it finally feels like the real thing.
Thatβs why the lightweight junobuild/satellite image still exists β and still works just as it always has. Itβs ideal for CI pipelines, isolated app testing, or local startup when you donβt need the Console and more infrastructure.
This shift in approach isnβt a breaking change. It adds a new default, but doesnβt remove what was already there.
Looking ahead, there's an intention to simplify scripting even further by allowing Datastore and Storage definitions directly in the main juno.config file. The goal is to eventually phase out juno.dev.config and unify configuration β but thatβs for the future.
For now, everything remains compatible. You choose what fits best.
If you already have a project configured for local development and want to switch to the new approach:
Update the CLI:
npm i -g @junobuild/cli
Remove your juno.dev.config.ts (or the JavaScript or JSON equivalent)
Update your docker-compose.yml to use the junobuild/skylab image (adjust paths as needed for your project):
services: juno-skylab: image: junobuild/skylab:latest ports: # Local replica used to simulate execution - 5987:5987 # Little admin server (e.g. to transfer ICP from the ledger) - 5999:5999 # Console UI (like https://console.juno.build) - 5866:5866 volumes: # Persistent volume to store internal state - juno_skylab:/juno/.juno # Your Juno configuration file. # Notably used to provide your development Satellite ID to the emulator. - ./juno.config.mjs:/juno/juno.config.mjs # Shared folder for deploying and hot-reloading serverless functions # For example, when building functions in TypeScript, the output `.mjs` files are placed here. # The container then bundles them into your Satellite WASM (also placed here), # and automatically upgrades the environment. - ./target/deploy:/juno/target/deploy/ volumes: juno_skylab:
Thatβs it β youβre good to go.
β Closing Thoughts
This shift removes a lot of friction between idea and execution.
You build in the same structure, use the same tools, and follow the same workflows you'd use in production β but locally, and instantly.
Local development finally feels like you're already in production, just without the pressure.
Stay connected with Juno by following us on X/Twitter.
Why Data Validation Matters in Decentralized Appsβ
Data validation is always important. However, web3 comes with its own set of challenges which makes validation an even more important part of building trustworthy apps:
No Central Administrator: Unlike traditional systems, decentralized apps have no admin backdoor to fix data issues
Limited Data Access: Developers often can't directly access or examine user data due to encryption and/or privacy
Data Immutability: Once written to the blockchain, data can be difficult or impossible to modify
Client-Side Vulnerability: Front-end validation can be bypassed by determined users (like in web2)
Security Risks: Invalid or malicious data can compromise application integrity and user trust
Getting validation right from the start is not just a best practiceβit's essential for the secure and reliable operation of your application.
on_set_doc is a Hook that is triggered after a document has been written to the database. It offers a way to execute custom logic whenever data is added or updated to a collection using the setDoc function executed on the client side.
This allows for many use-cases, even for certain types of validation, but this hook runs after the data has already been written.
// Example of validation and cleanup in on_set_doc #[on_set_doc(collections =["users"])] fnon_set_doc(context:OnSetDocContext)->Result<(),String>{ // Step 1: Get all context data we'll need upfront let collection = context.data.collection; let key = context.data.key; let doc =&context.data.data.after;// Reference to the full document after update let user_data:UserData=decode_doc_data(&doc.data)?;// Decoded custom data from the document // Step 2: Validate the data if user_data.username.len()<3{ // Step 3: If validation fails, delete the document using low-level store function delete_doc_store( ic_cdk::id(),// Use Satellite's Principal ID since this is a system operation collection, key, DelDoc{ version:Some(doc.version),// Use the version from our doc reference } )?; // Log the error instead of returning it to avoid trapping ic_cdk::print("Username must be at least 3 characters"); } Ok(()) }
Issues:
The on_set_doc hook only executes AFTER data is already written to the database, which is not ideal for validation.
Since it only happens after the data is already written, it can lead to unwanted effects. For example: let's say a new data needs to be added to some list. If it is invalid, we can't add it to the list, but since the hook runs after the data is written, the data will be added to the list anyway before we can reject them. This adds unwanted complexity to your code, forcing the developer to manage multiple on_set_doc hooks in the same function.
Overhead: invalid data is written (costly operation) then might be rejected and need to be deleted (another costly operation)
Not ideal for validation since it can't prevent invalid writes
Can't return success/error messages to the frontend
There are also other Juno hooks, but in general, they provide a way to execute custom logic whenever data is added, modified, or deleted from a Juno datastore collection.
Custom Endpoints are Juno serverless functions that expose new API endpoints through Candid (the Internet Computer's interface description language). They provide a validation layer through custom API routes before data reaches Juno's datastore, allowing for complex multi-step operations with custom validation logic.
caution
This example is provided as-is and is intended for demonstration purposes only. It does not include comprehensive security validations.
usejunobuild_satellite::{set_doc_store,SetDoc};// SetDoc is the struct type for document creation/updates usejunobuild_utils::encode_doc_data; useic_cdk::caller; usecandid::{CandidType,Deserialize}; // Simple user data structure #[derive(CandidType, Deserialize)] structUserData{ username:String, } // Custom endpoint for user creation with basic validation #[ic_cdk_macros::update] asyncfncreate_user(key:String, user_data:UserData)->Result<(),String>{ // Step 1: Validate username (only alphanumeric characters) if!user_data.username.chars().all(|c| c.is_alphanumeric()){ returnErr("Username must contain only letters and numbers".to_string()); } // Step 2: Create and store document // First encode our data into a blob that Juno can store into the 'data' field let encoded_data =encode_doc_data(&user_data) .map_err(|e|format!("Failed to encode user data: {}", e))?; // Create a SetDoc instance - this is the required format for setting documents in Juno // SetDoc contains only what we want to store - Juno handles all metadata: // - created_at/updated_at timestamps // - owner (based on caller's Principal) // - version management let doc =SetDoc{ data: encoded_data,// The actual data we want to store (as encoded blob) description:None,// Optional field for filtering/searching version:None// None for new docs, Some(version) for updates }; // Use set_doc_store to save the document // This is Juno's low-level storage function that: // 1. Takes ownership of the document (caller's Principal) // 2. Adds timestamps (created_at, updated_at) // 3. Handles versioning // 4. Stores the document in the specified collection set_doc_store( caller(),// Who is creating this document String::from("users"),// Which collection to store in key,// The document's unique key doc // The SetDoc we prepared above ).await }
While custom endpoints offer great flexibility for building specialized workflows, they introduce important security considerations. A key issue is that the original setDoc endpoint remains accessible β meaning users can, to some extension, still bypass your custom validation logic by calling the standard Juno SDK methods directly from the frontend. As a result, even if you've added strict validation in your custom endpoints, the underlying collection can still be modified unless you take additional steps to restrict access.
The common workaround is to restrict the datastore collection to "controller" access so the public can't write to it directly, forcing users to interact only through your custom functions. However, this approach creates its own problems:
All documents will now be "owned" by the controller, not individual users
You lose Juno's built-in permission system for user-specific data access
You'll need to build an entirely new permission system from scratch
This creates a complex, error-prone "hacky workaround" instead of using Juno as designed
Key Limitations:
Original setDoc endpoint remains accessible to users
Users can bypass custom endpoint entirely by using Juno's default endpoints directly (setDoc, setDocs, etc)
Restricting collections to controller access breaks Juno's permission model
Requires building a custom permission system from scratch
The assert_set_doc hook runs BEFORE any data is written to the database, allowing you to validate and reject invalid submissions immediately. This is the most secure validation method in Juno as it integrates directly with the core data storage mechanism.
When a user calls setDoc through the Juno SDK, the assert_set_doc hook is automatically triggered before any data is written to the blockchain. If your validation logic returns an error, the entire operation is cancelled and any changes are rolled back, and the error is returned to the frontend. This ensures invalid data never reaches your datastore in the first place, saving computational resources and maintaining data integrity.
Unlike other approaches, assert_set_doc hooks:
Cannot be bypassed by end users
Integrate seamlessly with Juno's permission model
Allow users to continue using the standard Juno SDK
Keep validation logic directly in your data model
Conserve blockchain resources by validating before storage
Can reject invalid data with descriptive error messages that flow back to the frontend (unlike on_set_doc which runs after storage and can't return validation errors to users)
// Simple assert_set_doc example #[assert_set_doc(collections =["users"])] fnassert_set_doc(context:AssertSetDocContext)->Result<(),String>{ match context.data.collection.as_str(){ "users"=>{ // Access username from the document let data = context.data.data.proposed.data.as_object() .ok_or("Invalid data format")?; let username = data.get("username") .and_then(|v| v.as_str()) .ok_or("Username is required")?; // Validate username if username.len()<3{ returnErr("Username must be at least 3 characters".to_string()); } Ok(()) }, _ =>Ok(()) } }
Key Advantages:
Always runs BEFORE data is written - prevents invalid data entirely
Zero overhead - validation happens in memory before expensive on-chain operations
Cannot be bypassed or circumvented
Prevents invalid data from ever being written
Conserves resources by validating before storage
Integrates directly with Juno's permission model
Keeps validation (assert_set_doc) separate from business logic triggers (on_set_doc)
usejunobuild_satellite::{ set_doc, list_docs, decode_doc_data, encode_doc_data, Document,ListParams,ListMatcher }; useic_cdk::api::time; usestd::collections::HashMap; #[assert_set_doc(collections =["users","votes","tags"])] fnassert_set_doc(context:AssertSetDocContext)->Result<(),String>{ match context.data.collection.as_str(){ "users"=>validate_user_document(&context), "votes"=>validate_vote_document(&context), "tags"=>validate_tag_document(&context), _ =>Err(format!("Unknown collection: {}", context.data.collection)) } } fnvalidate_user_document(context:&AssertSetDocContext)->Result<(),String>{ // Decode and validate the user data structure let user_data:UserData=decode_doc_data(&context.data.data.proposed.data) .map_err(|e|format!("Invalid user data format: {}", e))?; // Validate username format (3-20 chars, alphanumeric + limited symbols) if!is_valid_username(&user_data.username){ returnErr("Username must be 3-20 characters and contain only letters, numbers, and underscores".to_string()); } // Check username uniqueness by searching existing documents let search_pattern =format!("username={};", user_data.username.to_lowercase()); let existing_users =list_docs( String::from("users"), ListParams{ matcher:Some(ListMatcher{ description:Some(search_pattern), ..Default::default() }), ..Default::default() }, ); // If this is an update operation, exclude the current document let is_update = context.data.data.before.is_some(); for(doc_key, _)in existing_users.items { if is_update && doc_key == context.data.key { continue; } returnErr(format!("Username '{}' is already taken", user_data.username)); } Ok(()) } fnvalidate_vote_document(context:&AssertSetDocContext)->Result<(),String>{ // Decode vote data let vote_data:VoteData=decode_doc_data(&context.data.data.proposed.data) .map_err(|e|format!("Invalid vote data format: {}", e))?; // Validate vote value constraints if vote_data.value <-1.0|| vote_data.value >1.0{ returnErr(format!("Vote value must be -1, 0, or 1 (got: {})", vote_data.value)); } // Validate vote weight constraints if vote_data.weight <0.0|| vote_data.weight >1.0{ returnErr(format!("Vote weight must be between 0.0 and 1.0 (got: {})", vote_data.weight)); } // Validate tag exists let tag_params =ListParams{ matcher:Some(ListMatcher{ key:Some(vote_data.tag_key.clone()), ..Default::default() }), ..Default::default() }; let existing_tags =list_docs(String::from("tags"), tag_params); if existing_tags.items.is_empty(){ returnErr(format!("Tag not found: {}", vote_data.tag_key)); } // Prevent self-voting if vote_data.author_key == vote_data.target_key { returnErr("Users cannot vote on themselves".to_string()); } Ok(()) } fnvalidate_tag_document(context:&AssertSetDocContext)->Result<(),String>{ // Decode tag data let tag_data:TagData=decode_doc_data(&context.data.data.proposed.data) .map_err(|e|format!("Invalid tag data format: {}", e))?; // Validate tag name format and uniqueness if!is_valid_tag_name(&tag_data.name){ returnErr("Tag name must be 3-50 characters and contain only letters, numbers, and underscores".to_string()); } // Check tag name uniqueness let search_pattern =format!("name={};", tag_data.name.to_lowercase()); let existing_tags =list_docs( String::from("tags"), ListParams{ matcher:Some(ListMatcher{ description:Some(search_pattern), ..Default::default() }), ..Default::default() }, ); let is_update = context.data.data.before.is_some(); for(doc_key, _)in existing_tags.items { if is_update && doc_key == context.data.key { continue; } returnErr(format!("Tag name '{}' is already taken", tag_data.name)); } // Validate description length if tag_data.description.len()>1024{ returnErr(format!( "Tag description cannot exceed 1024 characters (current length: {})", tag_data.description.len() )); } // Validate time periods validate_time_periods(&tag_data.time_periods)?; // Validate vote reward if tag_data.vote_reward <0.0|| tag_data.vote_reward >1.0{ returnErr(format!( "Vote reward must be between 0.0 and 1.0 (got: {})", tag_data.vote_reward )); } Ok(()) } fnvalidate_time_periods(periods:&[TimePeriod])->Result<(),String>{ if periods.is_empty(){ returnErr("Tag must have at least 1 time period".to_string()); } if periods.len()>10{ returnErr(format!( "Tag cannot have more than 10 time periods (got: {})", periods.len() )); } // Last period must be "infinity" (999 months) let last_period = periods.last().unwrap(); if last_period.months !=999{ returnErr(format!( "Last period must be 999 months (got: {})", last_period.months )); } // Validate each period's configuration for(i, period)in periods.iter().enumerate(){ // Validate multiplier range (0.05 to 10.0) if period.multiplier <0.05|| period.multiplier >10.0{ returnErr(format!( "Multiplier for period {} must be between 0.05 and 10.0 (got: {})", i +1, period.multiplier )); } // Validate multiplier step increments (0.05) let multiplier_int =(period.multiplier *100.0).round(); let remainder = multiplier_int %5.0; if remainder >0.000001{ returnErr(format!( "Multiplier for period {} must use 0.05 step increments (got: {})", i +1, period.multiplier )); } // Validate month duration if period.months ==0{ returnErr(format!( "Months for period {} must be greater than 0 (got: {})", i +1, period.months )); } } Ok(()) }
Remember: Security is about preventing unauthorized or invalid operations, not just making them difficult. assert_set_doc hooks provide the only guaranteed way to validate all data operations in Juno's Datastore.
βοΈ This blog post was contributed by Fairtale, creators of Solutio.
Solutio is a new kind of platform where users crowdfund the software they need, and developers earn by building it. Instead of waiting for maintainers or hiring devs alone, communities can come together to fund bug fixes, new features, or even entire tools β paying only when the result meets their expectations.
β¨ Local dev is now the default for apps (!)
π Scaffold serverless functions
π° Sputnik preview (WIP)
π¦ Updated all template dependencies
π Onboarding revamped
Morning! Great news for the Juno community, which has always used identity.internetcomputer.org as the default domain for authentication.
Internet Identity now supports passkeys on both of its domains!
This means it should no longer matters whether devs or users sign in via identity.internetcomputer.org or identity.ic0.app β the registered identity should work seamlessly across both. There are a few limitations, which is why II may prompt you to register your current device.
As a result, Iβve just launched a new, clean sign-in page with a single call to action! π
To address potential sign-in issues, the page still offers domain-specific methods as a fallback. Plus, I added a brand-new footer accessible on scrollβkind of really happy with that design. π
I just published a new version with improvements to user management and scalability:
π Added the ability to set limits on changes per user per collection
π« Introduced banning / unbanning users to help prevent misuse
β¨ Various enhancements & refinements across the board
Joining the wallet π΅ display party β balances and amounts are now displayed in USD on the Juno Console too! π
Kudos to KongSwap for sharing their exchange rate endpoint! π
While I was at it, I fixed a few navigation leftovers from the last version, reviewed the UX of all transaction modals, integrated the exchange feature into each of them, and... had some fun with the colors π¨
Iβm thrilled to unveil the first big feature of the year: a brand-new way for Juno devs to monitor your wallet and modules automatically! π
β Keeps cycles topped up when balances run low
π Sends email notifications for top-ups (opt-in)
π Set it up once, never again β itβs automatically applied to all future projects
Everything is controlled through the Mission Control β which means that developers remain the sole controllers!
π₯ Exciting update! A new feature to automatically monitor your wallet and modules is here! π
In this version, I also revised the navigation to integrate Analytics, Monitoring, Mission Control, and Wallet within the main panel. This update led to the introduction of new colors. π¨