---
url: https://mikrojs.dev/config.md
description: Complete reference for mikro.config.ts
---

# Configuration

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

```ts twoslash
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.

| Option              | Type                | Default                          | Description                                      |
| ------------------- | ------------------- | -------------------------------- | ------------------------------------------------ |
| `onPanic`           | `object`            | `{mode: 'restart', delay: 1000}` | What the device does after an uncaught exception |
| `watchdog.blocking` | `number \| false`   | `30000`                          | Max ms one event-loop turn may hold the loop     |
| `watchdog.feed`     | `number`            | off                              | Max ms between `watchdog.feed()` calls           |
| `watchdog.awake`    | `number`            | off                              | Max ms awake per wake cycle                      |
| `stackSize`         | `number` (bytes)    | firmware default                 | QuickJS C stack size                             |
| `memReserved`       | `number` (bytes)    | 64 KB, 16 KB without radios      | Heap reserved for native subsystems              |
| `wifi.country`      | `WifiCountryCode`   | none                             | WiFi regulatory country code                     |
| `wifi.hostname`     | `string`            | device name, else `mikrojs-<id>` | DHCP hostname advertised by the STA interface    |
| `logFile`           | `true \| object`    | off                              | Enable on-device file logging                    |
| `logFile.dir`       | `string`            | `'/appfs/logs'`                  | Directory the log file lives in                  |
| `logFile.maxSize`   | `number \| 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](/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](/api/watchdog) 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 twoslash
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](/developing-for-microcontrollers#configuring-memreserved) for more guidance.

### `wifi.country`

Two-letter [ISO 3166-1](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2) 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](https://github.com/espressif/esp-idf/blob/v6.0/components/esp_wifi/regulatory/esp_wifi_regulatory.txt) (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`](/cli#mikro-logs).

#### `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.

::: warning 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}

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.

| Option              | Type                                               | Default                                | Description                             |
| ------------------- | -------------------------------------------------- | -------------------------------------- | --------------------------------------- |
| `build.bundle`      | `boolean`                                          | `false`                                | Bundle 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` {#buildbundle}

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` {#buildminifier}

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

### `build.minifyLevel` {#buildminifylevel}

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` {#buildloglevel}

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](#env): `warn` in `production` (removing `console.debug`, `console.log` and `console.info` calls), `debug` in `development` and `test`. The CLI `--loglevel` flag overrides this setting.

::: tip 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'
```

:::

::: warning 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 {#target}

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.

| Option     | Type                           | Default | Description                                            |
| ---------- | ------------------------------ | ------- | ------------------------------------------------------ |
| `board`    | `string`                       | none    | The board this project targets, e.g. `esp32c6-generic` |
| `features` | `('wifi' \| 'ble' \| 'i2s')[]` | none    | Firmware features the app needs beyond its imports     |

### `board` {#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](/develop/creating-boards) (`@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` {#features}

Four modules each need a firmware feature:

| Module              | Feature |
| ------------------- | ------- |
| `mikro/wifi`        | `wifi`  |
| `mikro/http/server` | `wifi`  |
| `mikro/ble`         | `ble`   |
| `mikro/i2s`         | `i2s`   |

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 {#tsconfig-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 {#sim}

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

| Option         | Type               | Default           | Description                       |
| -------------- | ------------------ | ----------------- | --------------------------------- |
| `sim.memLimit` | `number \| string` | `'300k'`          | QuickJS heap memory limit         |
| `sim.fsLimit`  | `number \| string` | `'1472k'`         | Virtual filesystem size limit     |
| `sim.fsRoot`   | `string`           | `'.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](/developing-for-microcontrollers#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 {#env}

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

| Environment   | Commands                                        |
| ------------- | ----------------------------------------------- |
| `production`  | `mikro deploy`, `mikro build`                   |
| `development` | `mikro dev`, `mikro sim deploy`/`dev`/`profile` |
| `test`        | `mikro test`, `mikro sim test`                  |

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

```ts twoslash
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 twoslash
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'}},
  },
})
```

::: tip 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 twoslash
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',
  },
})
```
