A boilerplate for building native-feeling apps with Next.js 16. Ships as a workout tracker; swap in your own domain.
Stack: Next.js 16 (App Router) · React 19 · TypeScript · Tailwind CSS 4 · shadcn/ui · oRPC · TanStack Query · SQLite (better-sqlite3) · PWA
npm install
npm run dev # http://localhost:3000On iPhone: open in Safari, tap Share > Add to Home Screen. The app runs fullscreen with a native bottom tab bar.
The entire UI is a single-page app (src/app/page.tsx) that uses horizontal scroll-snap for tab navigation. This gives you swipe-between-tabs behavior identical to native iOS/Android apps, with no client-side router for tab changes.
<div class="h-dvh flex-col overflow-hidden"> ← full viewport height
<div class="flex-1 min-h-0 flex snap-x ← horizontal scroll container
snap-mandatory overflow-x-auto"
style="align-items: flex-start">
<TabPane style="height: {measured}px" /> ← each pane is exactly
<TabPane style="height: {measured}px" /> the container height
<TabPane style="height: {measured}px" />
...
</div>
<BottomNav class="shrink-0" /> ← IN the flex column, not fixed
</div>
Key files:
src/components/shared/AppShell.tsx— the outer shell and bottom navsrc/components/shared/TabContext.tsx— scroll tracking, URL sync, pane height measurementsrc/components/shared/TabPane.tsx— individual tab wrapper
Every decision here was made to work around iOS standalone PWA behavior. If you change the layout, read this section first.
Do NOT use position: fixed; bottom: 0 for the bottom nav. On iOS standalone PWAs, fixed bottom-0 places the element at the viewport bottom (793px on an iPhone 15), which is visually above the home indicator. There is always a system-drawn gap below it.
The fix: the nav is a normal flex child (shrink-0) inside the h-dvh flex-col container. iOS resolves h-dvh in a flex column to include the home indicator region, so flex layout pushes the nav to the true screen bottom.
// AppShell.tsx — the nav is INSIDE the h-dvh div
<div className="h-dvh flex flex-col overflow-hidden">
<div className="flex-1 min-h-0 ..."> {/* scroll container */}
{children}
</div>
<BottomNav className="shrink-0" /> {/* normal flow, not fixed */}
</div>If you move the nav outside this container or add fixed/absolute positioning, it will float above the screen bottom on iOS.
Flexbox align-items: stretch (the default) causes all tab panes to expand to the height of the tallest pane's content. A tab with 200px of content would have thousands of pixels of dead scrollable space because it inherits the height of the longest tab.
CSS solutions (h-full, 100dvh, -webkit-fill-available, Grid) all fail on iOS Safari.
The fix is two parts:
align-items: flex-starton the scroll container — prevents cross-axis stretching- ResizeObserver in
TabContextmeasures the container'sclientHeightand exposes it aspaneHeight. EachTabPanesetsheight: ${paneHeight}pxas an inline style. An explicit pixel value cannot be misinterpreted.
// TabContext.tsx — measures the scroll container
useEffect(() => {
const ro = new ResizeObserver(([entry]) => {
setPaneHeight(entry.contentRect.height);
});
ro.observe(container);
return () => ro.disconnect();
}, []);
// TabPane.tsx — applies the measured height
<div style={{ height: h ? `${h}px` : '100%' }}>iOS draws a system-controlled strip below the app viewport. Its color comes from background_color in manifest.json, the theme-color meta tag, and the body's computed background-color.
Two things make this strip invisible:
-
Hex fallback colors on
htmlandbody— iOS can't parse oklch, so the CSS custom properties alone won't work. Always set a hexbackground-coloralongside them. -
Body stretch —
min-height: 100dvhandpadding-bottom: env(safe-area-inset-bottom)on body force it to fill the full viewport and paint its background into the home indicator region.
/* globals.css */
body {
background-color: #f5f0e8; /* hex fallback iOS can read */
min-height: 100dvh; /* stretch to full viewport */
padding-bottom: env(safe-area-inset-bottom); /* paint into safe area */
}The layout includes an inline <script> in <head> that reads localStorage.theme and applies the dark class synchronously, before React hydrates. This prevents the white-flash-then-dark-mode problem.
// layout.tsx
<script dangerouslySetInnerHTML={{
__html: `(function(){try{var t=localStorage.getItem('theme');var d=t==='dark'||(t!=='light'&&matchMedia('(prefers-color-scheme:dark)').matches);var h=document.documentElement;h.classList.toggle('dark',d);h.style.colorScheme=d?'dark':'light';h.style.backgroundColor=d?'#1a1714':'#f5f0e8'}catch(e){}})()`
}} />Because the iOS standalone viewport already excludes the safe area, env(safe-area-inset-bottom) returns 0. The nav still includes padding-bottom: calc(env(safe-area-inset-bottom, 0px) / 2) for devices/modes where it is nonzero, but don't rely on it for layout.
public/sw.js handles offline support:
- Navigation requests: network-first, falls back to cache
- Static assets: stale-while-revalidate (serves cached instantly, updates in background)
- API calls (
/rpc/): always go to network, never cached
After layout changes, bump CACHE_NAME in sw.js (e.g. app-v2 → app-v3). iOS can hold onto the old service worker even after closing and reopening the app.
Tab switching works via container.scrollTo({ behavior: 'smooth' }). The TabContext tracks scroll position with requestAnimationFrame during scroll and derives the active tab from Math.round(scrollLeft / clientWidth).
URLs update via history.replaceState (no full page navigation) after scroll settles. Deep links work through URL hash (/#log, /#timer, etc.) — hash-to-tab mapping runs on mount.
The bottom nav supports drag-to-navigate: pointer-capture on the nav bar lets you slide your finger across tabs to switch between them.
Settings and Export are separate Next.js routes (/settings, /export) that render inside AppShell without a TabProvider. In this mode:
- The shell shows a "Back" header with a View Transitions API animation (iOS-like push/pop)
- The bottom nav renders as links back to the main app
- The outer div uses
min-h-dvhinstead ofh-dvhso content can scroll naturally
src/
├── app/
│ ├── layout.tsx # fonts, metadata, viewport, theme sync script
│ ├── page.tsx # single-page app shell (all tabs)
│ ├── providers.tsx # QueryClientProvider
│ ├── globals.css # tailwind, oklch theme, animations, iOS fixes
│ ├── rpc/[[...rest]]/ # oRPC catch-all API route
│ ├── settings/ # standalone settings page
│ └── export/ # standalone export page
├── components/
│ ├── shared/ # AppShell, TabContext, TabPane, HapticsProvider, etc.
│ ├── ui/ # shadcn/ui primitives (button, card, badge, etc.)
│ ├── today/ # Today dashboard
│ ├── log/ # Log forms (workout, rest day, note)
│ ├── timer/ # Workout timer
│ ├── history/ # Weekly review / stats
│ ├── templates/ # Workout template management
│ ├── goals/ # Goal tracking
│ ├── settings/ # Settings screen
│ └── export/ # Data export/import
├── hooks/ # useProfile, useEvents, useSchedule, useTemplates, etc.
└── lib/
├── orpc.ts # oRPC client (browser-side)
├── types.ts # all TypeScript types
├── defaults.ts # default profile, schedule, templates
├── analytics.ts # streak calc, weekly review, insights
├── view-transition.ts # View Transitions API wrapper
├── sw-register.ts # service worker registration
└── server/
├── db.ts # SQLite setup + migrations
├── dao.ts # data access (CRUD for all tables)
└── router.ts # oRPC router (all API procedures)
- Database: SQLite via
better-sqlite3, stored atdata/app.db. WAL mode. Auto-migrates on first access. - API: oRPC with Zod validation. All calls go through
/rpc/— a single Next.js catch-all route. - Client state: TanStack Query. Hooks in
src/hooks/wrap oRPC calls with optimistic updates and cache invalidation. - No auth: single-user, device-local. The profile table has one row (id =
'default').
- Types — Replace the types in
src/lib/types.tswith your domain - Schema — Update the
migrate()function insrc/lib/server/db.ts - DAO — Rewrite
src/lib/server/dao.tsfor your tables - Router — Update procedures in
src/lib/server/router.ts - Hooks — Rewrite hooks in
src/hooks/to match your new API - Screens — Replace the screen components in
src/components/ - Tabs — Change
TAB_ROUTESinTabContext.tsxandNAV_ITEMSinAppShell.tsx - Defaults — Update
src/lib/defaults.tswith your initial data - PWA — Update
public/manifest.jsonwith your app name and icons - Theme — Edit the oklch values in
globals.css(keep the hex fallbacks in sync)
The shared infrastructure (AppShell, TabContext, TabPane, HapticsProvider, all UI components) should not need changes.
| What | Why |
|---|---|
Moving <BottomNav> outside the h-dvh div |
Nav floats above screen bottom on iOS PWA |
Adding position: fixed to the nav |
Same — doesn't reach screen bottom on iOS |
Removing align-items: flex-start from scroll container |
All panes stretch to tallest pane height |
| Removing the ResizeObserver pane height measurement | Panes won't have correct height on iOS |
Removing hex background-color fallbacks |
iOS renders black strip below app |
Removing min-height: 100dvh from body |
System-drawn strip visible at bottom |
Forgetting to bump CACHE_NAME in sw.js after changes |
iOS serves stale cached version |
| Using CSS viewport units for pane heights | iOS Safari ignores them in flex children |