July 31, 2025
Cafe Fiend
Cafe Fiend: A Cafe Discovery App
I'd consider myself a coffee enthusiast, and I found myself constantly searching for new cafes and espresso bars, kind of like collecting new Pokemon. Google Maps is great for basic searches; what's open now? what's near me? what's the ratings? but I wanted a way to filter out coffee shops that I had already seen and didn't want to visit. I also wanted a way to save favorites and maintain a wishlist like Google Maps does already. More importantly, as a developer, I wanted to experiment with TanStack Start and eventually add an AI search feature to the app.
I'm not a big fan of using Next.js these days, so seeing TanStack Start appear with server functions was a big reason to develop this app. I was curious about Tanner Linsley's full-stack React framework because his other offerings have been so immense. Start boasts having:
- Type-safe server functions to reduce API route boilerplate
- File-based routing to improve layout nesting and authentication
- Built-in data fetching to improve TanStack Query integration
- SSR/SSG flexibility to improve without the complexity
It's only in BETA as-of-now but it's a great piece of kit. For example, creating server functions is as simple as defining a function and decorating it with createServerFn
.
export const getFavorites = createServerFn({ method: "GET" }).handler(async () => {
const supabase = getSupabaseServerClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error("Unauthorized");
return await supabase
.from("saved")
.select("*")
.eq("user_id", user.id);
});
It is a joy not having to deal with separate API routes or manual type definitions. It's just pure TypeScript functions that can be placed just about anywhere. On top of the decision to use Start, I deliberately chose to accelerate development with AI-First Tooling. I used Tailwind CSS, Radix UI, and Lucide React. These choices allowed me to spend less time fighting with the AI and more time building the app. I prefer styled components for styling and I don't really know the ins-and-outs of Tailwind CSS yet, but I'm moving quicker.
I used v0.dev and similar AI-powered UI builders to get a rough idea of how I wanted things to look. I described what I needed, got production-ready React components, and reduced my overall UI development time.
Another great thing with the framework is the that authentication is handled entirely through Supabase with cookie-based sessions. The route protection pattern is simple and pretty much picked from the docs.
// _authed.tsx - Layout route for protected pages
export const Route = createFileRoute("/_authed")({
beforeLoad: ({ context }) => {
if (!context.user) {
throw redirect({ to: "/login" });
}
},
});
Any route under _authed/
automatically requires authentication. The user context is populated at the root level and flows down through the route tree.
Core Features
The app uses Google Maps for autocomplete and location services. Which may be the death of the app before I even start.... I am eating through the Google Maps credits rapidly and I don't plan to pay for using my own app. Oh well!
The core features of the app is the location and preferences. The user can either:
- Use their current location (with permission)
- Search for a specific address via autocomplete, or
- Drag a marker to any position on the map
const { location, setLocation, getCurrentLocation } = useGeolocation();
// Auto-complete for manual location entry
<AutoComplete onPlaceSelect={setLocation} isLoading={isLoading} />
// Draggable marker for precise positioning
<DraggableAdvancedMarker position={location} setLocation={setLocation} />
I use Google Maps lists daily and already have a favorites and wishlist in use. So I wanted to support a third state that Google Maps doesn't have. Hidden Places: the places you never want to see again!!!
So on top of fetching nearby results, I combine the saved preferences, deduplicate by place ID, and respect the user's filters.
const { data: displayData } = useCafeFinder({
lat: location?.lat,
long: location?.lng,
filters: {
rating: 4.0,
radius: 2000,
reviews: 20,
options: new Set(["nearby", "favorites", "wishlist", "open now"])
}
});
The Supabase schema is super minimal. I had to update the schema a few times, having learned from trial and error what I wanted to add and remove. It was a learning experience... I had never used Row Level Security policies before so it took a while to get it right.
CREATE TABLE saved (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id),
place_id TEXT NOT NULL,
status TEXT NOT NULL, -- 'favorite', 'wishlist', 'hidden'
name TEXT,
latitude DECIMAL,
longitude DECIMAL,
rating DECIMAL,
user_rating_count INT,
price_level TEXT,
business_status TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(place_id, user_id)
);
The UNIQUE(place_id, user_id)
constraint ensures users can't duplicate entries, while the status
field handles all three interaction types. Using UPSERT
operations makes status changes atomic:
await supabase
.from("saved")
.upsert({
user_id: user.id,
place_id: shop.id,
status: CafeStatus.FAVORITE,
// ... other fields
}, {
onConflict: "place_id,user_id",
ignoreDuplicates: false
});
Every API call is wrapped in TanStack Query for caching and background updates. The query key includes location and filters, so moving the map or changing preferences automatically triggers fresh data while maintaining cache efficiency. The favorite/unfavorite actions use optimistic updates for instant feedback then too!
const { data, isLoading } = useQuery({
queryKey: ["coffeeShops", lat, long, filters, favorites],
queryFn: () => findNearbyCafes({ lat, long, filters }),
enabled: !!(lat && long),
refetchOnWindowFocus: false,
});
The next steps are to add an AI search feature to the app. I better move quick before I run out of credits!
#React #Vite #Tanstack Start #Tailwind #PostgreSQL