This documents the problems encountered while implementing a pet profile avatar selection feature (image or color) on a Rails 8 + Inertia.js + Svelte 5 stack.
Problem 1: Colors Were Not Stored in the DB
Symptoms
Looking at the initial code, pet card colors were displayed like this:
const PET_COLORS = ['#f3caa1', '#b7ddf9', '#d3c8ff', '#c5d5f4', '#ffd9aa']
function petColor(index: number): string {
return PET_COLORS[index % PET_COLORS.length]
}
Colors were determined by the order (index) in which pets were created. Since colors were not stored in the DB at all, even if a user changed a color, it would revert to the original on refresh.
Solution
Added an avatar_color column via migration with a default value.
# db/migrate/..._add_avatar_color_to_pets.rb
class AddAvatarColorToPets < ActiveRecord::Migration[8.0]
def change
add_column :pets, :avatar_color, :string, default: '#f3caa1', null: false
end
end
Also added allowed color value validation to the model.
# app/models/pet.rb
AVATAR_COLORS = %w[#f3caa1 #b7ddf9 #d3c8ff #c5d5f4 #ffd9aa].freeze
validates :avatar_color, inclusion: { in: AVATAR_COLORS }, allow_blank: true
validates :avatar_url, length: { maximum: 500 }, allow_blank: true
Problem 2: Cannot Call API Controller Directly from Web UI
Situation
An Api::V1::PetsController already existed. Initially I thought “I can just use this,” but was immediately blocked.
Cause
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
include Authenticatable # JWT Bearer token verification
before_action :authenticate_api_user!
end
The API controller uses Authorization: Bearer <jwt> header-based authentication. However, the Inertia.js-based web UI authenticates via cookie sessions. Calling Inertia’s router.patch() / router.delete() from a web page returns 401 since there is no Bearer token.
Solution
Instead of using API routes, added separate web-specific pet CRUD actions to mypage_controller.
# config/routes.rb
scope "mypage" do
get "pet-settings", to: "mypage#pet_settings", as: :mypage_pet_settings
get "pets/new", to: "mypage#new_pet", as: :mypage_new_pet
post "pets", to: "mypage#create_pet", as: :mypage_create_pet
get "pets/:id/edit", to: "mypage#edit_pet", as: :mypage_edit_pet
patch "pets/:id", to: "mypage#update_pet", as: :mypage_update_pet
delete "pets/:id", to: "mypage#destroy_pet", as: :mypage_destroy_pet
end
# app/controllers/mypage_controller.rb
def update_pet
pet = current_user.pets.find(params[:id])
pet.update!(web_pet_params)
redirect_to mypage_pet_settings_path
end
private
def web_pet_params
{
name: params[:name]&.strip,
breed: params[:breed]&.strip,
gender: params[:gender].presence || "unknown",
weight_g: params[:weight_kg].present? ? (params[:weight_kg].to_f * 1000).round : nil,
neck_cm: params[:neck_cm].presence,
chest_cm: params[:chest_cm].presence,
back_length_cm: params[:back_length_cm].presence,
waist_cm: params[:waist_cm].presence,
avatar_color: params[:avatar_color].presence,
avatar_url: params[:avatar_url].presence,
}.compact
end
Key point: Even when using the same Pet model, two controllers with different authentication methods coexist. API (JWT) vs Web (session) must be completely separated.
Problem 3: Immediate Reflection with Inertia router.patch in Svelte 5
When clicking an image or color in the avatar selection panel, it needed to save to the server immediately and reflect in the UI. Using Inertia’s router.patch updates only the props without a page reload.
// PetSettings.svelte
function selectImage(pet: Pet, url: string) {
router.patch(`/mypage/pets/${pet.id}`, {
avatar_url: url,
avatar_color: pet.avatar_color,
name: pet.name,
breed: pet.breed,
gender: pet.gender ?? 'unknown',
weight_kg: pet.weight_g ? (pet.weight_g / 1000).toString() : '',
}, {
preserveScroll: true,
onSuccess: () => { pickerPetId = null } // close panel
})
}
Note that without preserveScroll: true, the scroll jumps to the top after a patch.
Problem 4: Seed Updates Not Being Applied
Symptoms
Running db:seed again did not update avatar_color and avatar_url on existing records.
Cause
The existing code used this pattern:
Pet.find_or_create_by!(user: user, name: "Kongi") do |pet|
pet.breed = "Pomeranian"
pet.avatar_color = "#f3caa1"
# ...
end
The block in find_or_create_by! is only executed when creating a new record. For already existing records, the block is never executed at all.
Solution
Changed to the find_or_initialize_by + assign_attributes + save! pattern.
pet = Pet.find_or_initialize_by(user: user, name: "Kongi")
pet.assign_attributes(
breed: "Pomeranian",
avatar_color: "#f3caa1",
avatar_url: "/images/pets/pomeranian.jpg",
gender: "female",
weight_g: 2800,
)
pet.save!
find_or_initialize_by returns the existing object if found, or a new object if not. Then assign_attributes overwrites the attributes and save! handles both creation and update. This is the basic pattern for writing idempotent seeds.
Problem 5: Where to Get Preset Images
Chose to store preset images as DB URLs without a separate image upload feature. Images can be obtained for free by breed from the Dog CEO API.
# Example: fetching a Pomeranian image
curl "https://dog.ceo/api/breed/pomeranian/images/random" | jq -r .message
# -> https://images.dog.ceo/breeds/pomeranian/n02112018_4099.jpg
curl -o public/images/pets/pomeranian.jpg \
"https://images.dog.ceo/breeds/pomeranian/n02112018_4099.jpg"
Saving images to public/images/pets/ lets Rails serve them as static files. On the Svelte side, just use the /images/pets/pomeranian.jpg path.
One gotcha: the shih-tzu breed is written as shihtzu (no hyphen) in the Dog CEO API.
curl "https://dog.ceo/api/breed/shihtzu/images/random" # OK
curl "https://dog.ceo/api/breed/shih-tzu/images/random" # 404
Complete Flow Summary
[DB Migration]
-> avatar_color (string, default: '#f3caa1', not null)
-> avatar_url (string, nullable)
[Model]
-> AVATAR_COLORS constant + inclusion validation
[Routes / Controller]
-> API (JWT) and Web (session) fully separated
-> Web pet CRUD in mypage_controller
[Frontend - Svelte 5]
-> PetSettings: avatar click -> picker panel -> router.patch instant save
-> PetForm: useForm + $form.patch / $form.post
[Seeds]
-> find_or_initialize_by + assign_attributes + save! pattern
Takeaways
- API controllers and Web controllers must be separated when authentication methods differ. Even when using the same model, do not mix them.
- The “only executes on create” behavior of
find_or_create_by!blocks is a common mistake. For seeds that need updates, use thefind_or_initialize_bypattern. - Inertia.js’s
router.patch+preserveScroll: truecombination delivers SPA-level UX without any extra state management, and it is quite pleasant to work with.

💬 댓글
비밀번호를 기억해두면 나중에 내 댓글을 삭제할 수 있어요.