{"title":"Build a Pet Tracker","description":"Build and test a complete Amber V2 app with Grant, Micrate, SQLite, HTML, JSON, ECR, local CSS, and import maps","section":"guides","version":"v2","path":"guides/pet-tracker","canonical_url":"https://amberframework.org/docs/v2/guides/pet-tracker","markdown_url":"https://amberframework.org/docs/v2/guides/pet-tracker.md","inherited":false,"content_markdown":"# Build a Pet Tracker\n\nThis is the canonical first Amber V2 application. It uses the same supported\npath as the release test:\n\n- SQLite and Grant for real persisted records;\n- a reversible Micrate migration;\n- typed request validation;\n- generated create, read, update, and delete routes;\n- ECR pages and a shared form partial;\n- one `respond_with` action that serves HTML or JSON;\n- local CSS and browser-native JavaScript;\n- request specs and a compiled application binary.\n\n## 1. Generate the application\n\n**Run from: the parent directory where `pet_tracker/` should be created.**\n\n```bash\namber new pet_tracker --type web\ncd pet_tracker\n```\n\nThe default is SQLite. `shard.yml` contains Amber, Grant, and\n`crystal-sqlite3`; `config/database.cr` registers Grant's `primary` connection;\nand the environment YAML files point development and test at separate database\nfiles under `db/`.\n\n## 2. Generate the complete Pet resource\n\n**Run from: the `pet_tracker/` application root beside `shard.yml`.**\n\n```bash\namber generate scaffold Pet name:string:required species:string:required adopted:bool\n```\n\n**Generated output: files created by the scaffold plus the updated route file.**\n\n```text\nsrc/models/pet.cr\nsrc/schemas/pet_schema.cr\nsrc/controllers/pet_controller.cr\nsrc/views/pet/index.ecr\nsrc/views/pet/show.ecr\nsrc/views/pet/new.ecr\nsrc/views/pet/edit.ecr\nsrc/views/pet/_form.ecr\nspec/models/pet_spec.cr\nspec/controllers/pet_controller_spec.cr\ndb/migrations/<timestamp>_create_pets.sql\nconfig/routes.cr\n```\n\nThe generator adds `resources \"/pets\", PetController` inside the existing\n`routes :web` block. That one declaration owns the index, show, new, create,\nedit, update, and destroy routes.\n\n## 3. Inspect the model and request boundary\n\n**File: `src/models/pet.cr` — generated Grant model.**\n\n```crystal\nclass Pet < Grant::Base\n  connection primary\n  table pets\n\n  column id : Int64, primary: true\n  column name : String\n  column species : String\n  column adopted : Bool?\n\n  timestamps\nend\n```\n\n`name` and `species` are required because their generator arguments ended in\n`:required`. `adopted` is nullable while a new form is being built and receives\na database default when omitted.\n\n**File: `src/schemas/pet_schema.cr` — generated request validation.**\n\n```crystal\nclass PetSchema < Amber::Schema::Definition\n  content_type \"application/x-www-form-urlencoded\"\n\n  field :name, String, required: true\n  field :species, String, required: true\n  field :adopted, Bool\nend\n```\n\nAmber CLI `2.0.6` binds that schema automatically above\nthe generated `create` and `update` actions:\n\n**File: `src/controllers/pet_controller.cr` — generated controller excerpt.**\n\n```crystal\nclass PetController < ApplicationController\n  schema :create, PetSchema\n  schema :update, PetSchema\n\n  def create\n    schema = validated_as(PetSchema)\n    pet = Pet.new\n    pet.name = schema.name.not_nil!\n    pet.species = schema.species.not_nil!\n    pet.adopted = schema.adopted\n    # Save and redirect, or render the model failure.\n  end\nend\n```\n\nAmber validates browser input before the action assigns values to the Grant\nmodel. The generated HTML failure hook returns status 422 and re-renders\n`src/views/pet/new.ecr` or `src/views/pet/edit.ecr` with `@errors`; it does not\nturn the form into a JSON error. This is the released CLI `2.0.6` and framework\n`2.0.0-beta.5` path.\n\nDatabase constraints remain in the migration; request validation does not\nreplace them.\n\n## 4. Inspect and apply the migration\n\n**File: `db/migrations/<timestamp>_create_pets.sql` — generated reversible SQL.**\n\n```sql\n-- +micrate Up\nCREATE TABLE IF NOT EXISTS pets (\n  id INTEGER PRIMARY KEY AUTOINCREMENT,\n  name VARCHAR(255) NOT NULL,\n  species VARCHAR(255) NOT NULL,\n  adopted BOOLEAN DEFAULT FALSE,\n  created_at TIMESTAMP,\n  updated_at TIMESTAMP\n);\n\n-- +micrate Down\nDROP TABLE IF EXISTS pets;\n```\n\n**Run from: the application root.**\n\n```bash\namber database migrate\nAMBER_ENV=test amber database migrate\namber database status\n```\n\nThe first command writes `db/pet_tracker_development.db`; the second writes the\nisolated test database. Micrate records applied versions in each database, so\nrunning `migrate` again is safe.\n\n## 5. Understand the generated form\n\n**File: `src/views/pet/_form.ecr` — shared by new and edit pages.**\n\nThe generated partial chooses the correct action, includes the CSRF token, and\nuses method override for an edit:\n\n**File: `src/views/pet/_form.ecr` — generated shared form excerpt.**\n\n```ecr\n<form action=\"<%= @pet.persisted? ? \"/pets/#{@pet.id}\" : \"/pets\" %>\" method=\"POST\">\n  <%= csrf_tag %>\n  <% if @pet.persisted? %>\n    <%= hidden_field(\"_method\", \"PATCH\") %>\n  <% end %>\n\n  <%= label(\"name\") %>\n  <%= text_field(\"name\", value: @pet.name?) %>\n\n  <%= label(\"species\") %>\n  <%= text_field(\"species\", value: @pet.species?) %>\n\n  <%= checkbox(\"adopted\", checked: @pet.adopted? || false, value: \"true\") %>\n  <%= label(\"adopted\") %>\n\n  <%= submit_button(\"Save\") %>\n</form>\n```\n\nThe question-mark readers are deliberate: a new model has not received its\nrequired values yet, so the form must be able to read `nil` without raising.\nThe controller uses the non-null schema result before saving.\n\n## 6. Serve HTML and JSON from one action\n\nThe generated controller renders HTML. Add a JSON representation to the index\nwithout moving rendering logic into the model.\n\n**File: `src/controllers/pet_controller.cr` — replace only the generated\n`index` method. Leave `show`, `new`, `create`, `edit`, `update`, and `destroy`\nas generated.**\n\n```crystal\ndef index\n  @pets = Pet.all.to_a\n\n  respond_with do\n    html { render(\"index.ecr\") }\n    json { @pets.to_json }\n  end\nend\n```\n\nThe action loads records once. The `html` branch renders\n`src/views/pet/index.ecr`; the `json` branch serializes the same Grant records.\nThe request `Accept` header chooses the representation. Read\n[Respond With](../controllers/respond-with/) for negotiation and error cases.\n\n## 7. Give the index the Amber visual language\n\n**File: `src/views/pet/index.ecr` — replace the generated table with this\ncomplete view.**\n\n```ecr\n<main class=\"pet-shell\">\n  <header class=\"pet-hero\">\n    <p class=\"pet-eyebrow\">Pet Tracker · Amber V2</p>\n    <h1>Small records.<br><em>Good homes.</em></h1>\n    <p>Track the animals moving through the foster network.</p>\n    <a class=\"pet-action\" href=\"/pets/new\">Add a pet</a>\n  </header>\n\n  <nav class=\"pet-filters\" aria-label=\"Filter pets\">\n    <button type=\"button\" data-pet-filter=\"all\" aria-pressed=\"true\">All pets</button>\n    <button type=\"button\" data-pet-filter=\"waiting\" aria-pressed=\"false\">Looking for a home</button>\n    <button type=\"button\" data-pet-filter=\"adopted\" aria-pressed=\"false\">Adopted</button>\n  </nav>\n\n  <section class=\"pet-grid\" aria-label=\"Pets\">\n    <% @pets.each do |pet| %>\n      <% status = (pet.adopted? || false) ? \"adopted\" : \"waiting\" %>\n      <article class=\"pet-card\" data-pet-status=\"<%= status %>\">\n        <span class=\"pet-kind\"><%= HTML.escape(pet.species) %></span>\n        <h2><a href=\"/pets/<%= pet.id %>\"><%= HTML.escape(pet.name) %></a></h2>\n        <span class=\"pet-status\"><%= status == \"adopted\" ? \"Adopted\" : \"Looking for a home\" %></span>\n        <a href=\"/pets/<%= pet.id %>/edit\">Edit record</a>\n      </article>\n    <% end %>\n  </section>\n</main>\n```\n\n**File: `app/assets/stylesheets/app.css` — append this component layer after the generated\nstarter styles.**\n\n```css\n.pet-shell {\n  width: min(1120px, calc(100% - 40px));\n  margin-inline: auto;\n  padding-block: clamp(72px, 10vw, 132px);\n}\n\n.pet-hero { max-width: 780px; }\n.pet-eyebrow,\n.pet-kind,\n.pet-status {\n  color: var(--amber-accent-deep);\n  font-size: .72rem;\n  font-weight: 850;\n  letter-spacing: .12em;\n  text-transform: uppercase;\n}\n\n.pet-hero h1 {\n  margin: 0;\n  font-family: ui-serif, Georgia, serif;\n  font-size: clamp(4rem, 9vw, 7.5rem);\n  letter-spacing: -.055em;\n  line-height: .88;\n}\n\n.pet-hero h1 em { color: var(--amber-accent); }\n.pet-action { display: inline-flex; margin-top: 20px; font-weight: 850; }\n.pet-filters { display: flex; flex-wrap: wrap; gap: 8px; margin-block: 40px 22px; }\n.pet-filters button {\n  padding: 9px 13px;\n  border: 1px solid var(--amber-line);\n  border-radius: 999px;\n  background: #fffdf9;\n  color: var(--amber-muted);\n  font: inherit;\n  font-size: .76rem;\n  font-weight: 800;\n  cursor: pointer;\n}\n\n.pet-filters button[aria-pressed=\"true\"] { border-color: var(--amber-accent); background: var(--amber-accent); color: white; }\n.pet-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }\n.pet-card {\n  min-height: 250px;\n  padding: 28px;\n  border: 1px solid var(--amber-line);\n  border-radius: 20px;\n  background: rgba(255, 253, 249, .82);\n  box-shadow: var(--amber-shadow);\n}\n\n.pet-card[hidden] { display: none; }\n.pet-card h2 { margin: 54px 0 18px; font-family: ui-serif, Georgia, serif; font-size: 2.4rem; }\n.pet-card h2 a { text-decoration: none; }\n.pet-status { display: flex; margin-bottom: 24px; }\n\n@media (max-width: 780px) {\n  .pet-grid { grid-template-columns: 1fr; }\n  .pet-card { min-height: 0; }\n}\n```\n\nThis reuses the generated application's warm paper, amber accents, compact\nlabels, editorial scale, and card geometry. It does not copy the framework\nwebsite's character art or require an external design library.\n\n## 8. Add browser-native filtering\n\n**File: `app/assets/javascript/app.js` — replace the starter module with this behavior.**\n\n```javascript\ndocument.querySelectorAll(\"[data-pet-filter]\").forEach((button) => {\n  button.addEventListener(\"click\", () => {\n    const filter = button.dataset.petFilter;\n\n    document.querySelectorAll(\"[data-pet-filter]\").forEach((candidate) => {\n      candidate.setAttribute(\"aria-pressed\", String(candidate === button));\n    });\n\n    document.querySelectorAll(\"[data-pet-status]\").forEach((card) => {\n      card.hidden = filter !== \"all\" && card.dataset.petStatus !== filter;\n    });\n  });\n});\n```\n\nThe generated import map already loads this file as the `app` module. Filtering\nis progressive enhancement: the records and links remain usable without\nJavaScript.\n\n## 9. Test HTML, JSON, and persistence\n\n**File: `spec/controllers/pet_controller_spec.cr` — add this example inside\nthe generated `describe PetController` block.**\n\n```crystal\ndescribe \"GET /pets as JSON\" do\n  it \"returns the persisted collection\" do\n    headers = HTTP::Headers{\"Accept\" => \"application/json\"}\n    response = get(\"/pets\", headers: headers)\n\n    assert_response_success(response)\n    response.headers[\"Content-Type\"].should contain(\"application/json\")\n    response.body.should eq(\"[]\")\n  end\nend\n```\n\n**Run from: the application root.**\n\n```bash\nAMBER_ENV=test amber database migrate\namber assets build\namber assets check\ncrystal spec\ncrystal build src/pet_tracker.cr -o bin/pet_tracker\namber watch\n```\n\nOpen <http://127.0.0.1:3000/pets/new>, create a Pet, open its detail page, edit\nit, and return to the filtered index. Then request the second representation:\n\n**Run from: another terminal while `amber watch` is running.**\n\n```bash\ncurl -H 'Accept: application/json' http://127.0.0.1:3000/pets\n```\n\nAmber CLI's candidate release test automates this same database path: generate the Pet\nscaffold, migrate development and test, run the generated specs, build and boot\nthe application, prove invalid input returns an HTML 422 with field errors,\ncreate a valid Pet through the ECR form, edit it through `_method=PATCH`, and\nread the updated record back.\n\n## Where to go next\n\n- [Web Template](../web-template/) explains every generated baseline file.\n- [Grant models](../models/grant/) covers queries, validations, associations,\n  callbacks, transactions, and security.\n- [Migrations](../models/grant/migrations/) covers authored Micrate changes and\n  release-safe database workflows.\n- [Views](../views/) expands the controller, ECR, partial, and layout boundary.\n- [Import Maps](../assets/import-maps/) shows how to split local browser code.\n- [Beta Support](../../beta-support/) separates the supported web path from\n  authentication, API-resource, Gemma, and native previews."}