Skip to content

Configuration ​

Project configuration lives in mikro.config.ts at the root of your project, next to package.json.

ts
import {
defineConfig
} from 'mikro'
export default
defineConfig
({
// options here })

defineConfig is a no-op identity function that provides type checking and autocompletion.

Runtime options ​

These options are bundled into the deployed app and read by the firmware at boot.

OptionTypeDefaultDescription
onPanicobject{mode: 'restart', delay: 1000}What the device does after an uncaught exception
watchdog.blockingnumber | false30000Max ms one event-loop turn may hold the loop
watchdog.feednumberoffMax ms between watchdog.feed() calls
watchdog.awakenumberoffMax ms awake per wake cycle
stackSizenumber (bytes)firmware defaultQuickJS C stack size
memReservednumber (bytes)64 KB, 16 KB without radiosHeap reserved for native subsystems
wifi.countryWifiCountryCodenoneWiFi regulatory country code
wifi.hostnamestringdevice name, else mikrojs-<id>DHCP hostname advertised by the STA interface
logFiletrue | objectoffEnable on-device file logging
logFile.dirstring'/appfs/logs'Directory the log file lives in
logFile.maxSizenumber | string'64k'Rotate when file exceeds this size
logFile.flush'line' | 'error''error'When to flush buffered writes to flash

onPanic ​

When an uncaught exception reaches the top level (a "panic"), the device restarts. onPanic configures what happens between the exception and that restart.

delay is the time in milliseconds the device waits before acting (default 1000). While it waits, the protocol REPL stays responsive, so you can run mikro deploy, mikro clean, or mikro ... --recover to break a crash loop. Use a longer delay during development to give yourself time to recover the device; use a shorter delay in production so it restarts sooner.

  • {mode: 'restart', delay?} (default): restart after the delay.
  • {mode: 'deepSleep', delay?, duration}: enter deep sleep for duration ms after the delay, then restart on wake. This draws far less power than restarting straight away, which matters for battery-powered devices. The device is unreachable while asleep, so set a longer delay during development to keep a window for recovery.
ts
// Battery device: sleep immediately, retry every 10 minutes
onPanic: {mode: 'deepSleep', delay: 0, duration: 600000}

See Error Handling for details.

watchdog ​

Limits for the three watchdogs. Each one restarts the device through onPanic when it fires, so delay and the deep-sleep action apply to all of them. See the watchdog API page for what each one catches and how to size them.

  • blocking: how long one turn of the event loop may hold the device before the running code is interrupted with a stack trace. Default 30000. Set false to disable.
  • feed: how long the app may go without calling watchdog.feed(). Off unless set. The app defines what progress means; this sets how long without it is too long.
  • awake: how long the device may stay awake in one wake cycle, counted from boot. Off unless set. Nothing can extend it, so it bounds the whole cycle. Only for apps that wake, work, and deep-sleep; on a device that stays powered it is a reboot timer.

A wake-cycle app pairs awake with the deep-sleep panic action, and drops it on the bench with a per-environment override. Overrides replace the whole watchdog group, so watchdog: {} in development removes awake and leaves blocking at its default:

ts
import {
defineConfig
} from 'mikro'
export default
defineConfig
({
onPanic
: {
mode
: 'deepSleep',
delay
: 0,
duration
: 600_000},
watchdog
: {
awake
: 120_000},
env
: {
development
: {
watchdog
: {}}},
})

Values below 1000 ms are raised to 1000 at boot with a warning.

stackSize ​

QuickJS C stack size. Controls how deep call stacks and recursion can go before hitting a stack overflow. The default is set by the firmware and is suitable for most apps. Only increase this if you hit InternalError: stack overflow on code that genuinely needs deep recursion.

memReserved ​

Amount of system heap to keep out of QuickJS's reach, reserved for native subsystems (WiFi, lwIP, TLS, HTTP client, C drivers). At runtime initialization, the QuickJS soft cap is computed as free_heap_at_init - memReserved.

The default is 64 KB, or 16 KB on firmware built without WiFi and BLE (such as the no-ble+no-wifi image), which has no network stack to reserve for.

  • If your app doesn't touch the network stack, lower it (16-32 KB). This leaves more of the heap for JS and module loading.
  • Keep the default (64 KB) for anything with WiFi + HTTPS. TLS handshakes need contiguous multi-KB buffers.
  • Don't go below ~8 KB unless you know exactly what your native side does. If system heap hits 0 while QuickJS still thinks it has budget left, that's a hard crash rather than a catchable JS OOM.

Use /mem in the REPL to verify: the System line should have a comfortable cushion over the QuickJS line. See Configuring memReserved for more guidance.

wifi.country ​

Two-letter ISO 3166-1 country code for WiFi regulatory domain. Controls which channels and power levels are available. Set this to your country to comply with local regulations and get the best WiFi performance. When unset, ESP-IDF defaults to world safe mode ("01", channels 1-11). The full list of supported codes is defined by the ESP-IDF regulatory database (168 countries/regions).

wifi.hostname ​

DHCP hostname advertised by the STA interface. This is what your router shows in its client list. Must conform to RFC 1123: letters, digits and hyphens only, must not start or end with a hyphen, max 63 characters.

When unset, the device's name (set with mikro name) is used, shaped into a valid hostname: lowercased, with . and _ replaced by hyphens (for example Living_Room.Sensor becomes living-room-sensor). An unnamed device falls back to mikrojs-<device-id> (for example mikrojs-abcdef0123), where the device ID is a base32-encoded MAC.

Takes effect on the next DHCP lease. A name change applies after the WiFi stack is next brought up, typically on the next restart.

logFile ​

Persist runtime output to a rotated file on the device filesystem for post-mortem debugging. Captures JS console.* output, native MIK_LOG calls, and ESP-IDF ESP_LOG lines. Each entry is prefixed with an ISO 8601 wall-clock timestamp (after SNTP sets the RTC) or [+SSS.fffs] relative to boot otherwise.

Set to true for sensible defaults, or pass an options object to override individual settings:

ts
export default defineConfig({
  logFile: true,
})

// or:
export default defineConfig({
  logFile: {
    dir: '/appfs/logs',
    maxSize: '128k',
    flush: 'line',
  },
})

Pull the file off the device with mikro logs pull.

logFile.dir ​

Directory on the device filesystem where the log file is stored. The file itself is always named log.txt, rotated as log.txt.1 once maxSize is reached. The directory is created on first boot if it doesn't exist.

logFile.maxSize ​

Maximum file size before rotation. Accepts a number of bytes or a string with K/M suffix (for example '64k' or '1m'). At the cap, log.txt is renamed to log.txt.1 (replacing any previous generation) and a fresh log.txt is started. Total flash usage is bounded at 2 × maxSize.

logFile.flush ​

When buffered log writes are committed to flash. Tradeoff between forensic fidelity and flash wear:

  • 'error' (default) — buffer everything, flush only on warn/error lines. The post-mortem sweet spot: errors land on flash immediately, normal output rides the stdio buffer.
  • 'line' — flush after every newline. Strongest crash-recovery guarantee, hardest on flash. Use only if you genuinely need every line to survive a hard reset.

Flash budget

Logging to flash burns write cycles. Rotation caps usage at 2 × maxSize, but flush: 'line' will accelerate wear noticeably under chatty workloads. Default to 'error' unless you have a specific reason not to.

Build options ​

Build-time options that control how your code is compiled and bundled. These are stripped from the config before deploying to the device. Can also be overridden by CLI flags.

OptionTypeDefaultDescription
build.bundlebooleanfalseBundle into a single module via esbuild
build.minifier'esbuild' | 'terser' | 'swc''esbuild'Which minifier to use
build.minifyLevel'default' | 'max''default'Minification aggressiveness
build.logLevel'none' | 'error' | 'warn' | 'info' | 'debug''warn' in production, else 'debug'Build-time log level threshold

build.bundle ​

Bundle user code into a single module via esbuild, with tree-shaking. Firmware builtins (mikro/*) and native modules stay external.

Bundling reduces QuickJS per-module overhead and strips unused code paths, at the cost of less informative stack traces.

build.minifier ​

Which minifier to use. esbuild is always available. terser requires the terser package to be installed; swc requires @swc/core.

build.minifyLevel ​

Minification aggressiveness. 'default' uses safe transforms only. 'max' enables unsafe transforms (multiple compress passes, toplevel mangling, pure_getters, unsafe_arrows, unsafe_math, unsafe_methods) for the smallest possible output.

build.logLevel ​

Build-time log level. Console methods below this threshold are eliminated as dead code by the minifier. Levels from most to least verbose: debug > info > warn > error > none.

When unset, the level depends on the environment: warn in production (removing console.debug, console.log and console.info calls), debug in development and test. The CLI --loglevel flag overrides this setting.

Cascading removal with @__PURE__

If you have helper functions that compute values only used in log calls, annotate them with /* @__PURE__ */ so the minifier can cascade the removal:

ts
const m = /* @__PURE__ */ memoryUsage()
console.log(`[mem] heap: ${m.heapUsed}`)
// both statements eliminated at logLevel: 'warn'

High log verbosity can cause out-of-memory errors

Setting a higher log level (such as debug) keeps more console calls in the bundle. Each call site adds string formatting, template literal evaluation, and argument allocation at runtime. On memory-constrained devices this extra work can push the QuickJS heap past its limit and cause InternalError: out of memory. If you need verbose logging on a tight device, add it incrementally and monitor heap usage with /mem in the REPL.

Target options ​

Options that tell the CLI which board and firmware the project is for. They are read on the host and stripped from the config before deploying.

OptionTypeDefaultDescription
boardstringnoneThe board this project targets, e.g. esp32c6-generic
features('wifi' | 'ble' | 'i2s')[]noneFirmware features the app needs beyond its imports

board ​

mikro flash picks the board in this order: the --board flag, then board from the config, then the board in the project itself or its dependencies when there is exactly one, so a board package flashes its own board. With several, it takes the one for the connected chip; when several are for that chip, it asks which one, or stops and lists them without a terminal. Without board dependencies, the CLI detects the chip and uses its generic board, <chip>-generic.

A board's name is the name its firmware reports, usually the name of its board package (@acme/devboard), or @acme/boards/t-display for one board of several in a package. A generic board is <chip>-generic. Before it writes anything, mikro flash checks that the connected chip matches the board and stops if it does not.

features ​

Four modules each need a firmware feature:

ModuleFeature
mikro/wifiwifi
mikro/http/serverwifi
mikro/bleble
mikro/i2si2s

The build works out which features the app needs from its imports, and mikro deploy, mikro dev and mikro test stop when the connected firmware lacks one of them. The stock firmware has all three, so this only comes up with custom firmware that leaves a feature out.

An import counts when it is a static import. A module that the app only ever loads with import('mikro/ble') does not count, so the deploy goes through and the import() fails on a device without BLE. List the feature in features when the app cannot do without it:

ts
import {defineConfig} from 'mikro'

export default defineConfig({
  features: ['ble'],
})

Firmware that predates feature reporting is never checked.

TypeScript presets ​

The types of mikro/wifi, mikro/ble, mikro/i2s and mikro/http/server resolve only when the project's tsconfig.json grants their feature. Extend the preset for your chip:

json
{
  "extends": "mikro/tsconfig/esp32c6-generic"
}

There is one preset per chip (esp32-generic, esp32c3-generic, esp32c5-generic, esp32c6-generic, esp32s3-generic). The bare mikro/tsconfig grants every feature.

The presets grant features through customConditions. If your own tsconfig.json sets customConditions, it replaces the preset's list, so add the conditions you need to it: mikro:wifi, mikro:ble, mikro:i2s. Without them, an import of mikro/wifi reports "Cannot find module".

Simulator options ​

Simulator-specific options. These are stripped from the config before deploying to a real device.

OptionTypeDefaultDescription
sim.memLimitnumber | string'300k'QuickJS heap memory limit
sim.fsLimitnumber | string'1472k'Virtual filesystem size limit
sim.fsRootstring'.mikro/sim-fs'Filesystem sandbox root directory

All size options accept a number of bytes or a string with K/M suffix (for example '300k' or '1m').

The sim.fsLimit default matches the generic firmware's filesystem. If your device has a different partition table, set sim.fsLimit to the size that storageUsage().total reports on the device.

Per-environment overrides ​

Put shared config at the top level and override it per environment under env. There are three environments, each tied to commands:

EnvironmentCommands
productionmikro deploy, mikro build
developmentmikro dev, mikro sim deploy/dev/profile
testmikro test, mikro sim test

The active environment's overrides are merged over the base. The env map itself is not deployed:

ts
import {
defineConfig
} from 'mikro'
export default
defineConfig
({
// Shared by every environment.
wifi
: {
country
: 'NO'},
env
: {
production
: {
// Drop debug logging and minify for a smaller build.
build
: {
minifyLevel
: 'max',
logLevel
: 'warn'},
// Deep-sleep after a crash to save power.
onPanic
: {
mode
: 'deepSleep',
delay
: 0,
duration
: 600000},
// Write logs to flash for later inspection.
logFile
: true,
},
development
: {
// Keep all log output.
build
: {
logLevel
: 'debug'},
// Wait 5 seconds before restarting after a crash, leaving time to redeploy.
onPanic
: {
mode
: 'restart',
delay
: 5000},
},
test
: {
// Test fixtures may exceed the production readFile() limit.
fsReadMax
: '256k',
}, }, })

Fields you don't override come from the base; every environment here keeps wifi.country. An environment with no env entry uses the base config unchanged.

The merge is shallow: an override replaces a whole top-level field. Overriding build replaces the entire build object, not just the keys you set, so spread the base to keep the rest:

ts
import {
defineConfig
} from 'mikro'
const
build
= {
minifier
: 'esbuild',
minifyLevel
: 'max'} as
const
export default
defineConfig
({
build
,
env
: {
// Keep minifier + minifyLevel, raise the log level.
development
: {
build
: {...
build
,
logLevel
: 'debug'}},
}, })

Config environment vs .env.<mode>

The config environment is not the .env.<mode> file a command loads. mikro sim dev uses the development config but reads .env.simulator. The .env file can be finer-grained.

Full example ​

ts
import {
defineConfig
} from 'mikro'
export default
defineConfig
({
onPanic
: {
mode
: 'restart',
delay
: 500},
memReserved
: 32 * 1024,
wifi
: {
country
: 'NO', // Norway
hostname
: 'living-room-sensor',
},
logFile
: true,
build
: {
bundle
: true,
minifier
: 'esbuild',
minifyLevel
: 'max',
logLevel
: 'warn',
},
sim
: {
memLimit
: '512k',
fsLimit
: '2m',
}, })

Mikro.js is built with AI assistance, code and docs alike. Read the AI disclosure.