@navigate-ai/next
v0.1.19
Published
Clippy build plugin for **Next.js** (App Router and Pages Router). At compile time the plugin:
Readme
@navigate_ai/next
Clippy build plugin for Next.js (App Router and Pages Router). At compile time the plugin:
- Injects stable
data-clippy-idattributes into every interactive HTML element in your compiled output — never modifying your source files. - Analyzes your component tree to extract state variables, event handlers, and conditional render relationships.
- Discovers all routes, navigation links, and user-flow paths.
- Emits two JSON artifacts —
clippy-policy.jsonandclippy-selectors.json— that the Clippy runtime uses to execute user prompts locally without LLM calls.
Installation
npm install @navigate_ai/next
# or
pnpm add @navigate_ai/next
# or
yarn add @navigate_ai/next@navigate_ai/plugin-shared is a transitive dependency and is installed automatically. You do not need to install it separately.
Peer dependencies (already present in any Next.js project):
npm install --save-dev next webpack @babel/parser @babel/traverseQuick start
Wrap your Next.js config with withClippy:
// next.config.ts
import type { NextConfig } from 'next'
import { withClippy } from '@navigate_ai/next'
const nextConfig: NextConfig = {
// your existing Next.js config
}
export default withClippy(nextConfig, {
apiKey: process.env.CLIPPY_API_KEY!,
projectId: process.env.CLIPPY_PROJECT_ID!,
})// next.config.js (CommonJS)
const { withClippy } = require('@navigate_ai/next')
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = withClippy(nextConfig, {
apiKey: process.env.CLIPPY_API_KEY,
projectId: process.env.CLIPPY_PROJECT_ID,
})On every production build (next build) the plugin runs automatically — no additional scripts or CI steps required.
Configuration
interface ClippyPluginOptions {
/** Your Clippy API key. Get this from the Clippy developer dashboard. */
apiKey: string
/** Your Clippy project ID. Found in the project settings page. */
projectId: string
/**
* Skip uploading artifacts to the Clippy backend.
* Useful in local development or when you want to inspect the output
* before uploading. Artifacts are still written if localOutputDir is set.
* Default: false
*/
skipUpload?: boolean
/**
* Only run the plugin during production builds (NODE_ENV === 'production').
* When true, the plugin is a no-op in development mode (next dev).
* Default: false — the plugin runs on every build including dev.
*/
productionOnly?: boolean
/**
* Write the generated JSON artifacts to a local directory in addition to
* (or instead of) uploading them. Useful for inspecting output and for
* CI pipelines that handle uploads separately.
*
* Two files are written:
* <localOutputDir>/clippy-policy.json
* <localOutputDir>/clippy-selectors.json
*
* Example: '.clippy-output'
*/
localOutputDir?: string
/**
* How to upload artifacts to the Clippy backend.
*
* 'split' — upload clippy-policy.json and clippy-selectors.json as separate
* requests. Recommended for large projects.
* 'single' — bundle both artifacts into one gzipped payload.
*
* Default: 'split'
*/
artifactUploadMode?: 'single' | 'split'
/**
* Provide a custom upload function. When set, the built-in HTTP uploader
* is bypassed entirely. Use this to integrate with your own artifact
* storage, a proxy endpoint, or a CI/CD pipeline step.
*
* Example — upload to your own backend:
*
* uploadAdapter: {
* uploadArtifacts: async (artifacts) => {
* await myApi.uploadPolicy(artifacts.policy)
* await myApi.uploadSelectors(artifacts.selectorManifest)
* return { skipped: false, policyUploaded: true, selectorsUploaded: true, mode: 'split' }
* }
* }
*/
uploadAdapter?: {
uploadArtifacts?: (artifacts: PolicyArtifacts) => Promise<UploadResult>
}
}Recommended environment variable setup
# .env.local (never commit this)
CLIPPY_API_KEY=pk_live_xxxxxxxxxxxx
CLIPPY_PROJECT_ID=proj_xxxxxxxxxxxx# CI/CD environment variables
CLIPPY_API_KEY=pk_live_xxxxxxxxxxxx
CLIPPY_PROJECT_ID=proj_xxxxxxxxxxxxOutput files
The plugin produces two files per build. Both share the same buildId so the Clippy runtime can match them to the active build.
clippy-selectors.json
A flat, deduplicated manifest of every interactive element across all routes. This is what gets injected into LLM prompts for Tier 3 execution and used for selector health monitoring.
{
"version": "1.0.0",
"buildId": "abc123",
"generatedAt": "2026-06-02T10:00:00.000Z",
"selectors": [
{
// Stable ID injected as data-clippy-id in the compiled DOM.
// Format: ComponentName[-LabelText]-tag-lineNumber
"id": "DashboardForms-CreateForm-button-138",
// CSS selector that resolves this element in the live DOM.
// Always data-clippy-id when injection succeeded; falls back
// to aria-label, data-testid, or button:contains() selectors.
"selector": "[data-clippy-id='DashboardForms-CreateForm-button-138']",
// The React component that contains this element.
"component": "DashboardForms",
// The HTML tag of the injected element.
"tag": "button",
// Human-readable label derived from aria-label, visible text,
// placeholder, or name attribute. null when none is available.
"label": "Create Form",
// Every route where this element appears. Shared components
// (Input, Textarea, etc.) list all routes here rather than
// appearing as duplicate entries.
"routes": ["/dashboard/forms"]
}
]
}clippy-policy.json
The full knowledge document used by the Clippy Policy Executor for Tier 1 (no-LLM) flow execution and by the backend for Tier 3 LLM context enrichment.
{
"version": "1.0.0",
"buildId": "abc123",
"generatedAt": "2026-06-02T10:00:00.000Z",
"bundler": "webpack",
// All discovered routes with relative file paths.
"routes": [
{
"path": "/dashboard/forms",
"filePath": "app/dashboard/forms/page.tsx",
"isDynamic": false,
"params": [],
"layout": "app/dashboard/layout.tsx",
"routerType": "app",
"semantic": "forms dashboard"
}
],
// All interactive elements with their selector candidates.
"selectors": [
{
"clippyId": "DashboardForms-CreateForm-button-138",
"selector": "[data-clippy-id='DashboardForms-CreateForm-button-138']",
"tag": "button",
"component": "DashboardForms",
"label": "Create Form",
"route": "/dashboard/forms",
"filePath": "app/dashboard/forms/page.tsx",
"attributes": [{ "name": "type", "value": "submit" }],
"candidates": [
{ "type": "clippy_id", "value": "[data-clippy-id='...']", "confidence": 0.999 },
{ "type": "aria", "value": "button[aria-label='Create Form']", "confidence": 0.93 }
]
}
],
// Components with meaningful state or interaction data.
// Pure presentational components (Button, Card, etc.) are excluded.
"components": [
{
"name": "DashboardForms",
"filePath": "app/dashboard/forms/page.tsx",
"route": "/dashboard/forms",
"stateVariables": [
{ "name": "isOpen", "setter": "setIsOpen", "initialValue": "false" }
],
"interactions": [
{
"trigger": { "event": "onClick", "element": "button", "setsState": "isOpen" },
"effect": {
"type": "conditionalRender",
"rendersWhenTrue": "CreateFormModal",
"waitStrategy": "elementAppears",
"selector": "[data-clippy-component='CreateFormModal']"
}
}
]
}
],
// Multi-step navigation flows inferred from Link/anchor elements.
"flows": [
{
"flowId": "flow_1",
"page": "/auth/signup",
"intentPatterns": ["sign up", "create account", "register", "get started"],
"steps": [
{ "step": 1, "action": "navigate", "target": "[data-clippy-id='SignupContent-form-126']" },
{ "step": 2, "action": "transition", "target": "[data-clippy-id='PlanSelectionContent-button-176']" },
{ "step": 3, "action": "transition", "target": "[data-clippy-id='OTPVerificationPage-form-163']" }
]
}
]
}How stable selectors work
The plugin transforms JSX at compile time — not your source files — to add two attributes to every native HTML element (button, input, a, form, select, textarea, label):
<!-- Your source (never modified): -->
<button className="primary">Create Form</button>
<!-- Compiled output (injected by the plugin): -->
<button
className="primary"
data-clippy-id="DashboardForms-CreateForm-button-138"
data-clippy-component="DashboardForms"
>
Create Form
</button>The data-clippy-id format is ComponentName[-LabelText]-tag-lineNumber:
- ComponentName — the enclosing React component, or a route-derived name for page components (e.g.,
AdminTransactionsinstead ofPage). - LabelText — the element's visible text or
aria-label, sanitized to TitleCase, max two words. Omitted when no static text is available. - tag — the lowercase HTML tag.
- lineNumber — the element's position in the source file, acting as a stable tiebreaker.
IDs are stable across builds as long as the component name and the element's file position don't change. CSS class hashes change on every build; data-clippy-id does not.
React components (<Button />, <AlertDialog />) are never touched — only native HTML elements.
Router support
| Router | Detection | Notes |
|---|---|---|
| App Router (Next.js 13+) | Filesystem — app/**/page.tsx | Route groups like (auth) are stripped |
| Pages Router | Filesystem — pages/**/*.tsx | api/ directory skipped |
| React Router | AST — createBrowserRouter, <Route path> | Detected in src/ |
| TanStack Router | Filesystem — src/routes/**/*.tsx | Dot-separated filenames |
Using local output for inspection
During development, write artifacts locally to inspect them before uploading:
export default withClippy(nextConfig, {
apiKey: process.env.CLIPPY_API_KEY!,
projectId: process.env.CLIPPY_PROJECT_ID!,
localOutputDir: '.clippy-output',
skipUpload: true, // don't upload, just write locally
})Add .clippy-output to .gitignore.
CI/CD
The plugin runs during next build and uploads automatically. No extra CI step is needed. If you prefer to control the upload yourself:
withClippy(nextConfig, {
apiKey: process.env.CLIPPY_API_KEY!,
projectId: process.env.CLIPPY_PROJECT_ID!,
localOutputDir: 'dist/clippy',
skipUpload: true,
uploadAdapter: {
uploadArtifacts: async (artifacts) => {
// your upload logic
},
},
})Troubleshooting
data-clippy-id is not appearing in the DOM
The loader only runs on non-server webpack compilations. Confirm you are inspecting the client-side rendered HTML, not server-rendered markup before hydration. Also check that the element is a native HTML tag — React components like <Button /> are intentionally skipped.
IDs contain Page as the component name
This happens for files outside the app/ directory where route inference can't determine the route. Add an aria-label to the element — the label will override the generic name in the ID.
Selectors show button:contains(...) instead of data-clippy-id
The text:contains() fallback is used when the injected ID cannot be matched back to a discovered element (typically a line number mismatch between compilation passes). It still works at runtime but is less stable than the injected ID. Adding aria-label to the button resolves this permanently.
Upload is failing
Verify CLIPPY_API_KEY and CLIPPY_PROJECT_ID are set in your build environment. Set localOutputDir to confirm the artifacts are being generated correctly before troubleshooting the upload.
Plugin is running in development but I only want production
withClippy(nextConfig, {
apiKey: process.env.CLIPPY_API_KEY!,
projectId: process.env.CLIPPY_PROJECT_ID!,
productionOnly: true,
})Related packages
@navigate_ai/vite— same plugin for Vite projects@navigate_ai/plugin-shared— internal shared extractors (not needed directly)
