Back to articles
Article

How we rewrote the frontend — a Nuxt architecture with explicit dependencies, Vitest tests and rules for AI agents

Architecture First: Building a Jira Alternative in a Tiny Team, AI-Assisted — Part 23.
In the previous article we went through comments. Their UI, like the change history UI, was built by Nikolay. This article is about how he rewrote the whole frontend.

The short version

How we got a new frontend:

  1. The old UI had become inconvenient: it grew more complex with every new feature, and the backend developer did not know how to improve the UI and UX.
  2. The whole frontend was rewritten by Nikolay, a frontend developer who joined the project in the summer.
  3. In the new architecture every page explicitly declares the operations it needs, and the implementations of those operations live separately and call the API.
  4. The expected errors of an operation are part of its type: the page knows a request can return "access denied" or "issue not found" and shows it without exceptions.
  5. API types are generated from the backend's OpenAPI description.
  6. The code is checked with Vitest tests, linters and type checks before every commit.
  7. The architecture rules are written down in AGENTS.md: Nikolay works with AI a lot, and this file sets the rules for the agents that write code.

Why the UI had to be rewritten

We built the first version of the web UI ourselves, putting pages together from prototypes generated by AI, as described in the second part of the series. That was enough to start, but with every new feature (spaces, organizations, permissions, attributes) the UI got more complex and less convenient.

At that time Boards had one backend developer, who did not know how to improve the UI and UX. New screens kept being added, but the interface as a whole did not get better.

In the summer Nikolay, a frontend developer, joined the project. First he fixed a few things in the backend to make it easy to run the project locally; we will cover them in the next article. Then he rewrote the whole frontend: the pull request with the new UI changed almost five hundred files.

A page declares its dependencies

The core of the new architecture is that every page has a type listing the operations it needs. For example, the issue page:

export type IssuePageDeps = {
  deleteIssue: DeleteIssue
  issueDetails: IssueDetailsDeps
  moveIssue: MoveIssue
  updateIssue: UpdateIssue
  viewIssuePage: ViewIssuePage
}

Each operation is a separate type in its own file. It describes the input, the result and the errors to expect:

type DeleteIssueResult = null

type DeleteIssueError =
  | 'AccessDenied'
  | 'IssueNotFound'
  | 'TemporarilyUnavailable'

export type DeleteIssue = (input: {
  issueKey: string
}) => Promise<ActionResult<DeleteIssueResult, DeleteIssueError>>

An operation does not throw: it returns either a result or one of the listed errors. The list of expected errors is right there in the type, so the page knows the issue may already have been deleted or access to it revoked, and shows that to the user.

The page itself does not know how an operation is performed: over HTTP, from a cache or from test data.

Implementations live separately

Operation implementations live in the infrastructure folder, one file per operation. Only there are HTTP, generated API types and network error handling:

export const openApiDeleteIssue =
  (baseUrl: string): DeleteIssue =>
  async ({ issueKey }) => {
    const client = createApiClient(baseUrl)
    try {
      const response = await client.DELETE('/api/issues/{key}', {
        params: { path: { key: issueKey } },
      })
      switch (response.response.status) {
        case 401:
        case 403:
          return err('AccessDenied')
        case 404:
          return err('IssueNotFound')
        default:
          break
      }
      if (!response.response.ok) {
        return err('TemporarilyUnavailable')
      }
      return ok(null)
    } catch {
      return err('TemporarilyUnavailable')
    }
  }

Here HTTP statuses turn into errors the page understands. The page sees neither 404 nor the API DTOs, only IssueNotFound.

The types for API requests are not written by hand. Shortly before Nikolay joined, we turned on the OpenAPI description in the backend, and the frontend generates types from it with pnpm api (via openapi-typescript) and sends requests through openapi-fetch. If the backend contract changes, the frontend stops building.

The dependencies are assembled in the Nuxt route file. It creates the implementations and passes them to the page:

const deps = {
  deleteIssue: openApiDeleteIssue(baseUrl),
  issueDetails: {
    loadAssignees: openApiLoadAssignees(baseUrl),
    loadMoveBoards: openApiLoadMoveBoards(baseUrl),
    loadMoveSpaces: openApiLoadMoveSpaces(baseUrl),
    loadStatuses: openApiLoadStatuses(baseUrl),
  },
  moveIssue: openApiMoveIssue(baseUrl),
  updateIssue: openApiUpdateIssue(baseUrl),
  viewIssuePage: openApiViewIssuePage(baseUrl),
}

Dependencies are passed explicitly, top down. There is no global store or provide/inject for them: the page code shows everything it needs.

Tests with Vitest

The tests are written with Vitest. In the first version they mostly checked the functions that turn API responses into page data, and helper logic. Such tests run without a browser and without the backend.

Since a page depends only on operation types, it can be tested by substituting test implementations for the real ones. Browser component tests with such fake dependencies came later.

Before a commit a single command fixes formatting, checks types, runs the tests and builds the project:

"pre-commit": "pnpm fix && pnpm typecheck && pnpm test && pnpm build"

Rules for AI agents

Nikolay works with AI a lot and brought his approach to development into the Boards frontend. The architecture rules are written down in the AGENTS.md file, which starts with: "This file is the source of truth for changes in this repository". It is read both by people and by the AI agents that write code.

The file describes everything covered above: how dependencies are passed from the route to the page and its components, where contracts and implementations live, and that UI components must not import API types. There are also rules against unnecessary abstractions, for example not to extract a shared contract until it has a second real consumer, and not to add factories and wrappers where an implementation can perform the page operation directly.

Rules like these are especially useful when AI writes the code: an agent tends to add layers and shared services "just in case", and an explicit ban keeps the code simple.

How we split the work

Frontend tasks were taken from the same board in Boards, and changes came in as pull requests. The backend part of the changes was reviewed. The frontend we left entirely to Nikolay.

Results

  • The UI was rewritten when it stopped being convenient. It got more complex with every feature, and there was nobody to improve the UX.
  • A page declares its dependencies explicitly. Each operation is a separate type with a result and expected errors.
  • HTTP and API types live only in infrastructure. The page sees errors like IssueNotFound, not HTTP statuses.
  • API types are generated from the backend's OpenAPI. Contract mismatches are caught at build time.
  • Tests, linters and types before every commit.
  • The rules live in AGENTS.md. They are the same for people and for AI agents.

What comes next

Before rewriting the frontend, Nikolay changed a few things in the backend: the token moved from localStorage into an HttpOnly cookie, CORS settings appeared, and docker-compose for running the project locally. That is the subject of the next article.