Getting Started

Build your first Feature

AI generated The content on this page was written using large language models.

We have authentication and a Surface describing what Features can read. Now we’ll put Rough on the page: a button to open the Builder, and a place to display published Features.

Your app owns the surrounding UI. Put these wherever they make sense for your users; the dashboard paths below are examples, not required route names.

Display published Features

subscribeToRoughFeatures gives you the published Features on your Surface. mountRoughFeature displays each one in an element you provide. This helper adds and removes mounts as the list changes, leaving existing Features running:

lib/rough/show-features.ts
import { mountRoughFeature, subscribeToRoughFeatures } from '@roughapp/feature'
import type {
  RoughClient,
  RoughFeatureMount,
  RoughSurface,
} from '@roughapp/feature'

export function showRoughFeatures(options: {
  client: RoughClient
  surface: RoughSurface
  element: HTMLElement
}) {
  const { client, surface, element } = options
  const mounted = new Map<
    string,
    { container: HTMLElement; mount: RoughFeatureMount }
  >()

  function unmount(id: string) {
    const entry = mounted.get(id)
    if (!entry) return
    mounted.delete(id)
    entry.mount.unmount()
    entry.container.remove()
  }

  const unsubscribe = subscribeToRoughFeatures({
    client,
    surface,
    onChange(features) {
      const published = new Set<string>(features.map((feature) => feature.id))
      // Remove Features that are no longer published.
      for (const id of mounted.keys()) {
        if (!published.has(id)) unmount(id)
      }
      // Add new ones. Leave the others alone so they keep running.
      for (const feature of features) {
        if (mounted.has(feature.id)) continue
        const container = document.createElement('div')
        element.append(container)
        const mount = mountRoughFeature(container, {
          client,
          surface,
          featureId: feature.id,
        })
        mounted.set(feature.id, { container, mount })
      }
    },
  })

  // Call this when the page goes away.
  return function stop() {
    unsubscribe()
    for (const id of [...mounted.keys()]) unmount(id)
  }
}

The first list may be empty while Features load, and unpublished drafts aren’t included. Mounted Features follow new publications automatically; you don’t need to recreate them each time someone publishes an update.

Open the Builder

We’ll create a draft, then open it in the Feature Builder:

lib/rough/builder.ts
import { createRoughFeature, openRoughBuilder } from '@roughapp/feature'
import type { RoughClient, RoughSurface } from '@roughapp/feature'

export function featureCreator(client: RoughClient, surface: RoughSurface) {
  return async function createFeature() {
    const feature = await createRoughFeature({ client, surface })
    await openRoughBuilder({ client, surface, featureId: feature.id })
  }
}

Each call creates a new draft. Closing the Builder doesn’t delete that draft or cancel a build. To reopen an existing Feature, call openRoughBuilder with its ID instead of creating another one. Published Features also have an edit control.

Give the dashboard a lifecycle

This browser-only helper brings together the files we’ve written so far. It creates a fresh data adapter and Surface for the current account, then starts the client. When the view goes away, it unmounts Features before destroying the client.

The small queue waits for asynchronous teardown before starting another client. That matters when the user switches accounts or your framework remounts the view quickly.

lib/rough/dashboard.ts
import { featureCreator } from './builder'
import { startClient } from './client'
import { createDashboardData } from './data-source'
import { showRoughFeatures } from './show-features'
import { createDashboardSurface } from './surface'

type Options = {
  projectId: string
  userId: string
  accountId: string
  element: HTMLElement
  button: HTMLButtonElement
}

// This helper owns one dashboard view. Keep it in browser-only code.
let transition = Promise.resolve()
function schedule(task: () => void | Promise<void>) {
  transition = transition.then(task).catch((error) => {
    console.error('Could not update the Rough dashboard', error)
  })
}

export function attachDashboard(options: Options) {
  const { element, button, accountId } = options
  let cancelled = false
  let client: ReturnType<typeof startClient> | undefined
  let stopFeatures: (() => void) | undefined
  let onCreate: (() => void) | undefined
  button.disabled = true

  schedule(() => {
    if (cancelled) return
    const data = createDashboardData(accountId)
    const surface = createDashboardSurface(data)
    client = startClient(options)
    stopFeatures = showRoughFeatures({ client, surface, element })
    const createFeature = featureCreator(client, surface)
    onCreate = () => {
      button.disabled = true
      void createFeature()
        .catch((error) => console.error('Could not create a Feature', error))
        .finally(() => {
          if (!cancelled) button.disabled = false
        })
    }
    button.addEventListener('click', onCreate)
    button.disabled = false
  })

  return () => {
    cancelled = true
    button.disabled = true
    if (onCreate) button.removeEventListener('click', onCreate)
    schedule(async () => {
      stopFeatures?.()
      await client?.destroy()
    })
  }
}

This example owns one dashboard view. If you show Rough on several pages at once, move client ownership into a shared layout or provider instead of starting another client for each view. Keep one client per project, user, and account.

The example reports failures to the console to keep the integration code focused. Before shipping, show your app’s normal error UI for authentication, loading, and create failures too.

Add it to your page

First, add the browser component. Its effect owns the integration and cleans it up when the identity changes or the component unmounts:

app/dashboard/rough-dashboard.tsx
'use client'

import { useEffect, useRef } from 'react'
import { attachDashboard } from '@/lib/rough/dashboard'

type Props = { projectId: string; userId: string; accountId: string }

export default function RoughDashboard({
  projectId,
  userId,
  accountId,
}: Props) {
  const element = useRef<HTMLDivElement>(null)
  const button = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (!element.current || !button.current) return
    return attachDashboard({
      projectId,
      userId,
      accountId,
      element: element.current,
      button: button.current,
    })
  }, [projectId, userId, accountId])

  return (
    <>
      <div ref={element} />
      <button ref={button} type="button" disabled>
        Create a Feature
      </button>
    </>
  )
}

'use client' alone isn’t enough: Next.js also renders Client Components on the server. The SDK needs browser APIs, so load this component with ssr: false from a separate Client Component:

app/dashboard/rough-dashboard-loader.tsx
'use client'

import dynamic from 'next/dynamic'

export const RoughDashboardLoader = dynamic(() => import('./rough-dashboard'), {
  ssr: false,
})

Finally, read the session and Project ID in your Server Component. As in the token route, replace getSession with your own authentication helper:

app/dashboard/page.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { RoughDashboardLoader } from './rough-dashboard-loader'

export default async function DashboardPage() {
  const session = await getSession()
  if (!session) redirect('/login')
  const projectId = process.env.ROUGH_PROJECT_ID
  if (!projectId) throw new Error('Rough Project ID is missing')

  return (
    <RoughDashboardLoader
      projectId={projectId}
      userId={session.user.id}
      accountId={session.user.organisationId}
    />
  )
}

Only the non-secret Project ID and identity IDs cross into the browser. Don’t pass the private key as a prop.

React Strict Mode runs an extra effect setup/cleanup cycle in development. The dashboard helper handles that lifecycle; don’t disable Strict Mode to hide it. When the active account changes, refresh the server-provided session props through your app’s account-switch flow so the effect receives the new IDs.

Build something using your data

Sign in to your app and click Create a Feature. The Builder should list the capability you defined in step 3, with your own name and description.

Ask for something useful using that data. For our books example, we might say:

Show my books grouped by reading status. Include each book’s title and author.

For support tickets, you might ask for unresolved tickets grouped by priority instead. Use the capability names and fields you actually exposed; there’s no special prompt to copy.

Wait for the build, which can take a few minutes. The preview uses your fictional sample. Publish the Feature and close the Builder: it should now appear in your app, using the real data from your implementation.

If the preview works but the published Feature doesn’t, check your data-access request and its schema. If the Builder doesn’t open, check the token request and browser console. An HTMLElement is not defined error usually means the SDK was imported on the server.

Before moving on, confirm the published Feature shows your app’s data—not just a successful preview. A Query reads when called; next we’ll give the Feature a way to follow changes.

A note about Feature state

The data you expose through capabilities stays under your app’s access rules. A Feature can also save its own UI state. By default, that state is stored in browser local storage by Feature ID, not by user or account. If you need user/account isolation for that state, supply a scoped datastore when mounting; see the SDK reference. Don’t treat the default local storage as private per-user storage.