Tutorial/tutorials

Chapter 2: One Request, by Hand

Chapter 1 gave you an app with one page you did not write. This chapter has you write the next one from blank files, in the order the rest of the course uses: a failing test, a route, a controller, a page. Then you specify a second page with a test and hand it to the agent, and you watch the harness load the right rule for the file it is editing.

Chapter 2: One Request, by Hand

Chapter 1 gave you an app with one page you did not write. This chapter has you write the next one from blank files, in the order the rest of the course uses: a failing test, a route, a controller, a page. Then you specify a second page with a test and hand it to the agent, and you watch the harness load the right rule for the file it is editing.

What you'll learn:

  • How a request travels from routes/web.ts to a controller method to a Response
  • The difference between a plain response and an Inertia page, and when each is enough
  • What bun run codegen derives from a page's Props, and why pages.about.Index is a compile-time name
  • How to name a route and link to it with the typed route() helper
  • How the harness's glob-scoped rules reach the agent only when it edits the files they govern

Start the dev server if you stopped it, and keep it running in its own terminal:

bun run dev

1. The test first

The page does not exist yet. Say what it should do:

tests/AboutController.test.tsimport { beforeAll, describe, it } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'

describe('AboutController', () => {
  let http: TestApp

  beforeAll(async () => {
    http = await TestApp.fromApp(app)
  })

  it('serves the about page', async () => {
    const response = await http.get('/about').assertOk()
    await response.assertBodyContains('About Guren Blog')
  })
})
bun test

The new test fails with a 404: nothing answers /about. The two from chapter 1 still pass. Now make the new one pass, one layer at a time.

2. The route

A route maps a method and a path to a controller action. Replace routes/web.ts:

routes/web.tsimport { Router } from '@guren/core'
import HomeController from '../app/Http/Controllers/HomeController.js'
import AboutController from '../app/Http/Controllers/AboutController.js'

export function registerWebRoutes(router: Router): void {
  router.get('/', [HomeController, 'index'])
  router.get('/about', [AboutController, 'index']).name('about')

  // Health check endpoint for load balancers and uptime monitors
  router.get('/health', (c) => c.json({ status: 'ok' }))
}

Two things are new. [AboutController, 'index'] names a class and a method rather than a function: Guren instantiates the controller per request, so the method can read the request through this. And .name('about') gives the route a name. URLs change; names are what pages link to.

Run the test again and it fails differently: the import of AboutController cannot be resolved, so the app cannot boot. That is guren check's job too, but the test found it first.

3. The controller, plain response first

Create app/Http/Controllers/AboutController.ts:

app/Http/Controllers/AboutController.tsimport { Controller } from '@guren/core'

export default class AboutController extends Controller {
  async index(): Promise<Response> {
    return this.text('About Guren Blog')
  }
}
bun test

Green. A controller action is a method that returns a Response; this.text() builds a plain one. That is the whole contract, and it is worth seeing once without a page in the way, because everything else in a controller (this.inertia(), this.json(), this.redirect(), the validators you meet in chapter 4) is a different way of building that same Response.

Open http://localhost:3333/about. Plain text, as promised.

4. Now a page

A plain response is right for a health check or a webhook. A page needs HTML, and in Guren that is an Inertia page: a React component under resources/js/pages/ that receives its props from the controller. Create it:

resources/js/pages/about/Index.tsximport { Head, Link } from '@inertiajs/react'

interface Props {
  title: string
  description: string
}

export default function AboutIndex({ title, description }: Props) {
  return (
    <>
      <Head title={title} />
      <main className="min-h-screen bg-g-page font-sans text-g-text">
        <div className="mx-auto max-w-3xl space-y-6 px-6 py-12">
          <h1 className="flex items-center gap-3 text-3xl font-bold text-g-heading">
            <span aria-hidden className="h-7 w-[3px] shrink-0 rounded-full bg-[image:var(--g-tick)]" />
            {title}
          </h1>
          <p className="text-lg text-g-text-2">{description}</p>
          <Link href="/" className="text-sm text-g-accent-text transition hover:underline">
            Back to the front page
          </Link>
        </div>
      </main>
    </>
  )
}

The component's Props interface is not just for React. Codegen reads it and records that the page named about/Index takes a title and a description, both strings. Regenerate the manifests:

bun run codegen

.guren/pages.gen.ts now has pages.about.Index, and this.inertia() will refuse any props that do not match. Point the controller at the page:

app/Http/Controllers/AboutController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class AboutController extends Controller {
  async index(): Promise<Response> {
    return this.inertia(pages.about.Index, {
      title: 'About Guren Blog',
      description: 'A blog built chapter by chapter, by hand and by agent.',
    })
  }
}
bun test

Still green, and now for the reason you wanted: the body contains the title because the controller sent it as a prop. Reload /about in the browser: the page, rendered on the server first, then taken over by React in the browser. Try removing description from the controller and running bun run typecheck: the error names the page and the missing prop. Put it back.

Three things to take from this section:

  • The page name is the file path. resources/js/pages/about/Index.tsx is pages.about.Index. Rename the file and the name follows after the next codegen, and every controller that used the old name stops compiling.
  • Props are the contract. The controller sends exactly what the page declares. There is no second place where the shape is written down.
  • Codegen is not a build step you remember to run. bun run dev runs it at startup and watches routes, pages and resources for changes; bunx guren gate runs it first. You ran it by hand here to see what it does.

Confirm the whole change and commit it:

bunx guren gate
git add -A
git commit -m "feat: add the about page"

5. Specify the next slice

A contact page, built the same way. This time you write only the test:

tests/ContactController.test.tsimport { beforeAll, describe, it } from 'bun:test'
import { TestApp } from '@guren/testing'
import app from '../src/app.js'

describe('ContactController', () => {
  let http: TestApp

  beforeAll(async () => {
    http = await TestApp.fromApp(app)
  })

  it('serves the contact page', async () => {
    const response = await http.get('/contact').assertOk()
    await response.assertBodyContains('Contact')
    await response.assertBodyContains('hello@guren-blog.test')
  })
})
bun test

Red. That test is the specification: whoever builds the page, it is done when this passes.

One thing to know before you hand it over. In the test runner, a page is not rendered to HTML; the response carries the page's name and its props, and assertBodyContains searches that. So the test can see anything the controller sends and nothing the component merely writes. The address has to be a prop. That is not a limitation to work around: it is the test telling you where content belongs, and it is why chapter 1's tagline was a prop too.

6. Delegate it

Ask your agent, inside guren-blog:

Add a /contact page the way /about was built: a ContactController with an index action that sends title: 'Contact' and email: 'hello@guren-blog.test' as props, a page at resources/js/pages/contact/Index.tsx that shows the title as a heading and the email as a mailto link, and a route named contact in routes/web.ts. tests/ContactController.test.ts already describes it; make it pass.

While it works, watch for the harness lever of this chapter. The agent's context does not hold every rule at once. .claude/rules/routes-codegen.md starts like this:

---
description: Guren routing & codegen — RouteContractOptions, schema binding, the Zod→ApiRoutes matrix, middleware
globs:
  - "routes/**"
  - "app/Http/Validators/**"
---

The globs line is the point: the rule is loaded when the agent edits a file under routes/, and not before. controllers-http.md does the same for app/Http/**. So when the agent opens routes/web.ts, it is handed the exact shape of router.get(...), the options object, and .name(), verified against this version of the framework, at the moment it needs them. It cannot invent a route API from memory because the real one is in front of it. And when it saves the file, the PostToolUse hook runs guren check, which would report a route pointing at a controller method that does not exist.

No agent handy? Three files. (An agent may well start with bunx guren make:controller Contact, which writes a controller skeleton that already renders pages.contact.Index; chapter 3 is about that habit.)

app/Http/Controllers/ContactController.tsimport { Controller } from '@guren/core'
import { pages } from '@/.guren/pages.gen'

export default class ContactController extends Controller {
  async index(): Promise<Response> {
    return this.inertia(pages.contact.Index, {
      title: 'Contact',
      email: 'hello@guren-blog.test',
    })
  }
}
resources/js/pages/contact/Index.tsximport { Head, Link } from '@inertiajs/react'

interface Props {
  title: string
  email: string
}

export default function ContactIndex({ title, email }: Props) {
  return (
    <>
      <Head title={title} />
      <main className="min-h-screen bg-g-page font-sans text-g-text">
        <div className="mx-auto max-w-3xl space-y-6 px-6 py-12">
          <h1 className="flex items-center gap-3 text-3xl font-bold text-g-heading">
            <span aria-hidden className="h-7 w-[3px] shrink-0 rounded-full bg-[image:var(--g-tick)]" />
            {title}
          </h1>
          <p className="text-lg text-g-text-2">
            Write to <a href={`mailto:${email}`} className="text-g-accent-text hover:underline">{email}</a>.
          </p>
          <Link href="/" className="text-sm text-g-accent-text transition hover:underline">
            Back to the front page
          </Link>
        </div>
      </main>
    </>
  )
}
routes/web.tsimport { Router } from '@guren/core'
import HomeController from '../app/Http/Controllers/HomeController.js'
import AboutController from '../app/Http/Controllers/AboutController.js'
import ContactController from '../app/Http/Controllers/ContactController.js'

export function registerWebRoutes(router: Router): void {
  router.get('/', [HomeController, 'index'])
  router.get('/about', [AboutController, 'index']).name('about')
  router.get('/contact', [ContactController, 'index']).name('contact')

  // Health check endpoint for load balancers and uptime monitors
  router.get('/health', (c) => c.json({ status: 'ok' }))
}

Either way, regenerate and run the specification:

bun run codegen
bun test

Review before you accept. The rubric:

  • routes/web.ts has one new line, a GET /contact route named contact, and its import. Nothing else moved.
  • ContactController has one action and renders a page; it does not build HTML by hand or return this.text().
  • The address is a prop the controller sends, not text the page hard-codes; resources/js/pages/contact/Index.tsx declares both props in its Props interface.
  • tests/ContactController.test.ts is unchanged and green, and so is everything else.
bunx guren gate
git add -A
git commit -m "feat: add the contact page"

Where you are

  • You have traced one request through every layer and written each layer yourself.
  • You know what codegen derives from a page, and that the page name and its props are checked at compile time.
  • You have specified a page with a test before it existed, delegated it, and accepted it against a rubric.
  • You have seen a rule reach the agent because of the file it opened, not because it was asked.

Common trip-ups

  • pages.about.Index does not exist. Codegen has not run since the page was created. bun run codegen, or let bun run dev do it; the dev server regenerates when a page is added while it is running.
  • The test passes but the browser shows the old page. The dev server rendered it before your last save and Inertia kept the old props. Reload with the cache off, or check the terminal running bun run dev for a codegen error.
  • The agent returned this.text() with HTML in it. It works and the test passes, which is why the rubric says what the controller must do, not only what the test checks. Ask it to render the page instead; that is the fix you will make many times in this course.
  • guren check warns that a controller has no test. It looks for tests/<Name>Controller.test.ts. You wrote both; if the warning names another controller, that is chapter 3's job.

Exercises

  1. /health is an inline handler, not a controller. On a branch, add a second inline route that answers with the current time, and say what you lose by writing a route this way rather than as a controller action.
  2. this.inertia(pages.about.Index, …) takes a page from generated code, not a string. Change it to a page that does not exist and read the TypeScript error. That error is the whole argument for pages.*; put it back.

Next

Chapter 3: The Posts Table adds the first database table, a model, and the two pages that read it, then hands the create form to the agent.