Most people who use the Happy Pet Tech SaaS work on their feet. A groomer is next to a dog. Kennel staff are walking between rooms. For them the product has to work on a phone.
The web app was built for desktop. It has about 175 screens, 120 modal dialogs, and wide tables almost everywhere. We had been serving phones with a separate mobile app, which meant building features twice. We decided to stop doing that. The web app itself would work on a phone and be installable, and all our effort would go into one product.
The main responsive work ran from June 3 to June 17 this year, and the app became installable on June 16. We kept fixing things through the summer. This is how we did the first part that fast, and what went wrong after.
Fix the shared pieces first
If you open 175 screens one by one and fix each, you will be there for months. So the first phase touched no screens at all. It fixed the shell: header, navigation, page padding, and the base components that every screen uses.
The best example is modals. We counted 120 modal components. But they were built on two base components. One was used in 101 places, the other in 26. We made those two behave well on a phone: full width, scroll inside, buttons reachable with a thumb. That single change fixed about 127 usages.
After that came 20 phases, one area of the product at a time. Two of the commits touched 53 and 115 files.
Tables become cards
A table with eight columns cannot fit on a phone. Horizontal scroll technically works and nobody likes it.
Our shared Table component got one new prop, mobileCards. With it, each row renders as a small card on narrow screens, with the column name next to each value. A screen opts in by adding the prop, so nothing changed for screens that were not ready. It is now used in 64 files.
Pickers and small forms use bottom sheets, the panel that slides up from the bottom of a phone screen. There are 14 of those now.
One breakpoint, and three test widths
We chose 768 pixels as the line between phone and desktop layouts, and checked every screen at three widths: 390, 768 and 1280.
There was a trap here. Our Tailwind config had a container width of 790, and it was tempting to use that as the breakpoint. A tablet in portrait is 768 wide. With 790 it would have been given the phone layout. Our plan document has a note about exactly this.
A gate for screens that are not ready
We did not want to wait for all 175 screens before shipping. We also did not want a user to open an unfinished screen on a phone and see a broken layout.
So there is a gate. It holds a list of routes that are ready for small screens. On a phone, a route that is not on the list shows a simple page: “Best on desktop and laptop”. On June 17, 117 of the 175 routes were on the list.
Some screens are desktop-only by decision. Settings is the main one. It has long forms for services, prices, taxes and roles. People set those up once, at a desk. We chose not to spend time squeezing them into a phone.
The gate caused two bugs that I did not see coming:
- Printing. A printed page is narrower than 768 pixels. So when someone printed an invoice from a desktop, the print layout counted as a phone, and the PDF showed “Best on desktop and laptop”.
- Customer documents. Pages that we send to a pet owner, like an invoice link, were being gated when a signed-in staff member opened them on a phone.
Both needed an exception in the gate.
Making it installable
An installable web app needs a manifest and a service worker.
The manifest tells the phone the name, the icons, the colours, and that the app opens in its own window. In Next.js it is a small file, app/manifest.js. Two things in ours are worth copying:
- A maskable icon. Android cuts icons into circles and rounded squares. Without a maskable icon it shrinks yours and puts it on a white plate.
- Screenshots for wide and narrow screens. With them, Chrome shows a richer install dialog that looks like a store page.
The service worker has a fetch handler that passes every request to the network and caches nothing. Browsers want a fetch handler before they call an app installable. We did not want offline caching, because a cached app shell serves yesterday’s code after a deploy. What we do have is a small overlay that appears when the phone goes offline, so the user knows why nothing is loading.
What broke after install
Running inside its own window on a phone is a slightly different environment from a browser tab. These are the bugs we hit.
A blank chart on iPhone. In the installed app, the dashboard chart was empty. The chart was drawing before its container had a size, so it drew into a box of zero pixels. Waiting two animation frames before drawing fixed it, with a 400 ms fallback.
Sideways screens on tablets. Our manifest locked the orientation to portrait. A tablet on a stand is in landscape, and the whole UI showed sideways. The manifest now says orientation: 'any'.
The app kept opening the wrong page. An installed app should reopen where you left it. We first did that in middleware on the server. But the server cannot tell an installed app from a browser tab, so it also redirected people who had typed a URL. We moved it to the client, where we can check display-mode: standalone.
The install button we removed. At launch we had our own “Install app” button, with different instructions for each browser. Five weeks later we deleted it. Browsers already offer install in their own menu, and we now leave that job to them.
Navigation came last
For the first months, phone navigation was a hamburger menu that opened a drawer. It worked, and it was two taps for everything.
This week we replaced it on the main screens with a bottom tab bar, a “More” page, and a round create button. The bar is 56 pixels high plus the safe area of the phone, so it clears the home indicator on an iPhone:
.main-with-tabbar {
padding-bottom: calc(56px + max(env(safe-area-inset-bottom, 0px), 10px));
}
@media (min-width: 768px) {
.main-with-tabbar { padding-bottom: env(safe-area-inset-bottom, 0px); }
}
What I would tell another team
- Count your base components before you count your screens. Two modal components were 127 screens for us.
- Make responsive an opt-in prop on shared components, so you can ship screen by screen.
- Gate what is not ready. A clear “use a desktop for this” is better than a broken page.
- Decide what will never be on a phone, and say so.
- Test the installed app on a real iPhone and a real tablet. Three of our bugs only existed there.