NNO Docs
Guides

Feature Development Guide

Documentation for Feature Development Guide

Audience: Developers building @neutrino-io/feature-* packages for the NNO console shell Last updated: 2026-03-30

Contracts first: Before implementing, read the Feature Package SDK spec for the full FeatureDefinition interface and the Shell Config spec for auto-discovery mechanics. This guide is a practical walkthrough — those specs are the authoritative contracts.


1. Overview

A feature package is a self-contained npm package that plugs into the NNO console shell. It declares its routes, sidebar navigation, and permissions in a FeatureDefinition object. The shell discovers it at build time via the neutrino-feature-discovery Vite plugin — no manual registration required.

Integration flow:

Install @neutrino-io/feature-my-feature


Vite plugin scans package.json → finds "neutrino": {"type": "feature"}


Generates virtual:feature-registry with static import


Shell reads featureManifest → merges with features.config.ts


FeatureRegistry loads FeatureDefinition → builds routes + sidebar

Two exports drive this integration:

ExportFilePurpose
featureManifestsrc/manifest.tsAuto-discovery metadata (load priority, groups, sub-routes)
\{id\}FeatureDefinitionsrc/feature.tsFull contract: routes, navigation hierarchy, permissions

2. Prerequisites

  • Working nno-app-builder checkout with pnpm install completed
  • Node 20+, pnpm 9+
  • Familiarity with React 19 and TypeScript
  • Read the Feature Package SDK spec — especially FeatureDefinition, FeatureRoute, and FeatureNavItem

3. Scaffold a Feature Package

Create your package directory under features/:

features/my-feature/
├── package.json          # Must declare "neutrino": {"type": "feature"}
├── tsconfig.json
├── tsup.config.ts
└── src/
    ├── index.ts          # Public barrel — exports featureManifest + FeatureDefinition
    ├── manifest.ts       # FeatureManifest for auto-discovery
    ├── feature.ts        # FeatureDefinition (routes, navigation, permissions)
    ├── routes/           # Page-level components
    │   └── MyFeaturePage.tsx
    └── components/       # Feature-scoped UI components

package.json

The "neutrino": \{"type": "feature"\} field is required for auto-discovery. Follow the pattern from features/settings/package.json and features/billing/package.json:

{
  "name": "@neutrino-io/feature-my-feature",
  "version": "0.1.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    }
  },
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "clean": "rm -rf dist",
    "typecheck": "tsc --noEmit",
    "lint": "eslint",
    "test": "vitest run"
  },
  "dependencies": {
    "@neutrino-io/sdk": "workspace:*",
    "@neutrino-io/ui-core": "workspace:*"
  },
  "devDependencies": {
    "@neutrino-io/eslint-config": "workspace:*",
    "@neutrino-io/tsconfig": "workspace:*",
    "@types/react": "catalog:react19",
    "tsup": "catalog:",
    "typescript": "catalog:"
  },
  "peerDependencies": {
    "react": "catalog:react19",
    "react-dom": "catalog:react19"
  },
  "publishConfig": {
    "registry": "https://npm.pkg.github.com",
    "access": "restricted"
  },
  "neutrino": {
    "type": "feature"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/neutrino-io/nno-app-builder.git",
    "directory": "features/my-feature"
  }
}

tsconfig.json

{
  "extends": "@neutrino-io/tsconfig/base.json",
  "compilerOptions": {
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "outDir": "dist",
    "rootDir": "src",
    "declaration": true,
    "declarationMap": true,
    "composite": false,
    "noEmit": false,
    "jsx": "react-jsx"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"]
}

tsup.config.ts

import { defineConfig } from "tsup";

export default defineConfig({
  entry: { index: "src/index.ts" },
  format: ["cjs", "esm"],
  dts: false,
  sourcemap: false,
  clean: true,
  esbuildOptions(options) {
    options.jsx = "automatic";
    options.jsxImportSource = "react";
  },
  external: [
    "react",
    "react-dom",
    "react/jsx-runtime",
    "@neutrino-io/sdk",
    "@neutrino-io/ui-core",
    "zod",
  ],
  treeshake: true,
  bundle: true,
});

4. Implement featureManifest

src/manifest.ts provides the auto-discovery metadata consumed by the Vite plugin and features.config.ts. Keep it minimal — it is not the routing contract.

// src/manifest.ts
import type { FeatureManifest } from "@neutrino-io/sdk/types";

export const featureManifest: FeatureManifest = {
  id: "my-feature",
  name: "My Feature",
  package: "@neutrino-io/feature-my-feature",
  module: "features/my-feature",
  enabledByDefault: false,        // false = opt-in; true = active on install
  loadPriority: 90,               // 10–89 = domain features; 90–98 = client features
  lazyLoad: true,
  environment: "all",
  domain: "my-feature",
  type: "business",
  group: "Overview",              // Sidebar group heading
  defaultComponent: "MyFeaturePage",
};

enabledByDefault: false is the safe default for new features. The platform operator activates it via the NNO Portal or features.config.ts.

See the Shell Config spec §FeatureManifest for the full field reference.


5. Define Routes

src/feature.ts exports the canonical FeatureDefinition. The shell's FeatureRegistry resolves \{id\}FeatureDefinition (e.g. myFeatureFeatureDefinition) first; fall back to \{id\}FeatureConfig is for legacy packages only.

// src/feature.ts
import type { FeatureDefinition } from "@neutrino-io/sdk/feature";
import type { FeatureRoute } from "@neutrino-io/sdk/types";

const routes: FeatureRoute[] = [
  {
    path: "/my-feature",
    component: "MyFeaturePage",
    auth: true,
    layout: "default",
    permissions: ["my-feature:read"],
    meta: { title: "My Feature" },
  },
  {
    // Dynamic segment — TanStack Router $param syntax
    path: "/my-feature/detail/$id",
    component: "MyFeatureDetailPage",
    auth: true,
    permissions: ["my-feature:read"],
    meta: { title: "Detail" },
  },
];

export const myFeatureFeatureDefinition: FeatureDefinition = {
  id: "my-feature",
  version: "0.1.0",
  displayName: "My Feature",
  description: "Short description shown in the NNO Portal",
  icon: "Zap",                    // Lucide icon name

  routes,
  navigation: [/* see Section 6 */],
  permissions: ["my-feature:read"],

  requiresService: false,         // true if a backend Worker is needed
};

export default myFeatureFeatureDefinition;

Path conventions (paths are registered as-is — no shell prefix added for FeatureDefinition-based routes):

PathUse case
/my-featureFeature root
/my-feature/detail/$idDynamic segment
/my-feature/settingsNested sub-page

See Feature SDK spec §1.2 for the full FeatureRoute interface.


6. Add Navigation

Declare navigation inside the FeatureDefinition. The shell's sidebar generator reads navigation[0].children to build collapsible sub-menus. Icon strings are resolved to lucide-react components by the shell — feature packages do not need to import lucide-react for icon declarations.

Flat sidebar entry:

navigation: [
  {
    label: "My Feature",
    path: "/my-feature",
    icon: "Zap",          // Resolved to <Zap /> by the shell
    order: 90,
    group: "Overview",
    permissions: ["my-feature:read"],
  },
],

Collapsible sub-menu (modelled on features/settings/src/feature.ts):

navigation: [
  {
    label: "My Feature",
    path: "/my-feature",
    icon: "Zap",
    order: 90,
    children: [
      { label: "Dashboard", path: "/my-feature",          icon: "LayoutDashboard", order: 1 },
      { label: "Detail",    path: "/my-feature/detail",   icon: "FileText",        order: 2 },
      { label: "Settings",  path: "/my-feature/settings", icon: "Settings",        order: 3 },
    ],
  },
],

order controls sort position within the group (ascending, default 99). children are sorted independently.

See Feature SDK spec §1.3 for the full FeatureNavItem interface including badge and per-item permissions.


7. Use SDK Hooks

Import hooks from @neutrino-io/sdk. The SDK has zero runtime dependencies on the shell itself, so features remain independently testable.

import { useNnoSession } from "@neutrino-io/sdk";

// Access the authenticated user and their permissions
const { user } = useNnoSession();
const canWrite = user.permissions.includes("my-feature:write");
import { useServiceUrl } from "@neutrino-io/sdk";

// Resolve the backend Worker URL (reads VITE_* env vars, then env.config.ts)
const apiUrl = useServiceUrl("VITE_MY_FEATURE_API_URL");

For features that need a backend Worker, set requiresService: true and serviceEnvKey: "VITE_MY_FEATURE_API_URL" in the FeatureDefinition. The NNO CLI Service provisions the Worker and injects the URL.

Pair server state fetching with TanStack Query — do not aggregate data directly in components:

import { useQuery } from "@tanstack/react-query";
import { useServiceUrl } from "@neutrino-io/sdk";

function useMyFeatureData() {
  const apiUrl = useServiceUrl("VITE_MY_FEATURE_API_URL");
  return useQuery({
    queryKey: ["my-feature"],
    queryFn: () => fetch(`${apiUrl}/items`).then((r) => r.json()),
  });
}

8. Use ui-core Components

Import shared components from @neutrino-io/ui-core. Do not copy or reimplement them in your feature package.

import { PageHeader, DataTable } from "@neutrino-io/ui-core";

export function MyFeaturePage() {
  return (
    <div>
      <PageHeader
        title="My Feature"
        description="Feature description shown below the title"
      />
      <DataTable columns={columns} data={data} />
    </div>
  );
}

Common components: PageHeader, DataTable, Card, Button, Badge, EmptyState. Check the @neutrino-io/ui-core exports for the full list — add @neutrino-io/ui-core as a dependency (not devDependency) and mark it external in tsup.config.ts.


9. Wire Up the Barrel

src/index.ts is the package's public API. Export both the featureManifest (required for auto-discovery) and the FeatureDefinition:

// src/index.ts

// Page components consumed by the shell's route loader
export { MyFeaturePage } from "./routes/MyFeaturePage";
export { MyFeatureDetailPage } from "./routes/MyFeatureDetailPage";

// Canonical FeatureDefinition — shell resolves myFeatureFeatureDefinition first
export { myFeatureFeatureDefinition, default } from "./feature";

// Auto-discovery manifest — required for Vite plugin
export { featureManifest } from "./manifest";

10. Test Locally

  1. Add the package to apps/console/package.json as a workspace dependency:

    "@neutrino-io/feature-my-feature": "workspace:*"
  2. Build the feature package:

    pnpm --filter @neutrino-io/feature-my-feature build
  3. Start the console:

    cd apps/console && pnpm dev
  4. Verify auto-discovery picked it up — open the browser console and look for the feature registry log, or navigate directly to /my-feature.

  5. Confirm the sidebar entry appears under the declared group.

Watch mode for active development:

# Terminal 1 — rebuild feature on change
pnpm --filter @neutrino-io/feature-my-feature dev

# Terminal 2 — console dev server (HMR picks up rebuilt dist)
cd apps/console && pnpm dev

11. Publish

Follow the Package Registry Guide for authentication setup and CI publishing. Summary:

  1. Bump version in package.json (semver).
  2. Build: pnpm --filter @neutrino-io/feature-my-feature build
  3. Publish: pnpm --filter @neutrino-io/feature-my-feature publish

Packages are published to https://npm.pkg.github.com under the @neutrino-io scope. CI publishes automatically on push to main when package files change.


12. Activate on a Platform

In the consumer platform repo (e.g. nno-stack-starter):

  1. Install the package:

    pnpm add @neutrino-io/feature-my-feature
  2. Auto-discovery handles registration — no edits to features.config.ts are needed. The shell merges the featureManifest on next build.

  3. To override settings (load priority, sidebar visibility, permissions), add an explicit entry to apps/console/src/config/features.overrides.ts.

  4. To enable a feature that ships with enabledByDefault: false, set enabled: true in features.config.ts or via the NNO Portal.


13. Feature Development Checklist

  • package.json declares "neutrino": \{"type": "feature"\} and follows @neutrino-io/feature-* naming
  • src/manifest.ts exports featureManifest with correct id, package, loadPriority, group
  • src/feature.ts exports \{id\}FeatureDefinition (camelCase) as the canonical export and as default
  • src/index.ts re-exports both featureManifest and \{id\}FeatureDefinition
  • All route path values match navigation paths exactly
  • permissions declared in FeatureDefinition follow \{feature-id\}:\{action\} format
  • tsup.config.ts marks react, react-dom, @neutrino-io/sdk, @neutrino-io/ui-core as external
  • tsconfig.json extends @neutrino-io/tsconfig/base.json with "jsx": "react-jsx"
  • Feature builds without TypeScript errors: pnpm --filter @neutrino-io/feature-my-feature typecheck
  • Sidebar entry appears and routes resolve in pnpm dev
  • Unit tests cover key components and hooks
  • publishConfig.registry points to https://npm.pkg.github.com

Related specs

On this page