From Modal to Full Page: How We Refactored a Vue 3 Recipe Detail View (and Made It Faster)
Demo:
https://recipe-finder.org/recipe/644488-german-rhubarb-cake-with-meringue
One of the longest-lived technical decisions in our recipe finder app was showing recipe details inside a dialog modal. It worked — until it didn't. Here's how we migrated from a bloated modal to a clean, SEO-friendly full page, what we cut along the way, and what the app looks like now.
The Old Approach: Everything in a Modal
The original setup opened a <v-dialog> when a user clicked a recipe card. The modal held the entire recipe detail UI: ingredients, nutrition, videos, AI chef, grocery import, recipe scaler — all of it. The logic for opening it, fetching the recipe, and handling deep-link slugs lived inside HomePage.vue.
<!-- Old: HomePage.vue controlled everything -->
<RecipeDetailsModal
:is-open="isRecipeModalOpen"
:recipe="selectedRecipeDetails"
:loading="loadingRecipeDetails"
@close="closeModal"
/>
The problem was that HomePage.vue had become a god component. It managed:
- The search form and results
- Cuisine carousel
- Recipe of the day
- Recent recipes
- And the modal open/close state, slug parsing, and detail fetch
On top of that, because everything was in a modal, the URL never changed. Users couldn't share a link to a specific recipe, Google couldn't index the content, and the Back button did nothing useful.
The New Approach: Dedicated Route + Page
We created /recipe/:slug as a proper route and moved the recipe detail logic into a standalone RecipeDetailPage.vue.
// router/index.ts
{
path: '/recipe/:slug',
component: () => import('@/pages/RecipeDetailPage.vue'),
meta: { title: 'Recipe' }
}
Slugs are derived from the recipe ID and title, making them human-readable and stable:
// utils/index.ts
export const toRecipeSlug = (id: number, title: string): string => {
const slug = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/(^-|-$)/g, '');
return `${id}-${slug}`;
};
export const extractRecipeIdFromSlug = (slug: string): number | null => {
const id = parseInt(slug.split('-')[0]);
return isNaN(id) ? null : id;
};
What We Cut From HomePage.vue
Once the page was independent, we stripped HomePage.vue of everything modal-related. Gone:
isRecipeModalOpen ref
selectedRecipeDetails and loadingRecipeDetails state
- The
openRecipeModal / closeModal handlers
- The slug-watching
watch that re-fetched on URL change
- The
<RecipeDetailsModal> import and component registration
- The
handleOpenRecipeDetails function passed down through three component layers
The result was HomePage.vue shrinking by roughly 40% in script size. It now does one thing: show the search form and results.
What the Page Layout Looks Like
The page uses a standard Vuetify two-column grid — main content on the left, sticky sidebar on the right (desktop only). On mobile, the sidebar collapses and the tools surface inline.
<v-row>
<!-- Left: main content -->
<v-col cols="12" lg="8">
<v-img :src="recipe.image" cover rounded="lg" class="mb-6" />
<h1 class="recipe-title">{{ recipe.title }}</h1>
<!-- chips, rating, action buttons, summary, ingredients, instructions, nutrition -->
</v-col>
<!-- Right: sticky sidebar (desktop only) -->
<v-col cols="12" lg="4" class="d-none d-lg-flex flex-column">
<div class="sidebar-sticky">
<!-- Recipe Tools card -->
<!-- AI Cooking Chef card -->
<!-- Nutrition Snapshot card -->
</div>
</v-col>
</v-row>
The sidebar cards use a glass morphism style that matches the rest of the app:
.glass-card {
border: 1px solid rgba(255, 255, 255, 0.1);
background:
radial-gradient(circle at top right, rgba(255, 163, 92, 0.08), transparent 40%),
linear-gradient(165deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02));
border-radius: 16px !important;
}
Mobile: Icon Buttons + Bottom Sheet AI
On mobile, the page can't show a sidebar. We solved this in two ways:
Action buttons become icon-only on small screens, matching how the modal used to look:
<!-- Mobile: icon-only -->
<div class="d-flex d-sm-none gap-2 mb-6 align-center">
<v-btn icon variant="tonal" size="small" @click.stop="handleToggleFavoriteRecipe">
<v-icon>{{ isFavorited ? 'mdi-heart' : 'mdi-heart-outline' }}</v-icon>
</v-btn>
<v-btn icon variant="tonal" size="small" @click="shareRecipe">
<v-icon>{{ isCopied ? 'mdi-check' : 'mdi-share-variant' }}</v-icon>
</v-btn>
<v-btn icon variant="tonal" size="small" @click="printRecipe">
<v-icon>mdi-printer</v-icon>
</v-btn>
</div>
<!-- Desktop: text buttons -->
<div class="d-none d-sm-flex flex-wrap gap-2 mb-6">
<v-btn prepend-icon="mdi-heart-outline" variant="tonal" class="page-action-btn">
Add to Favorites
</v-btn>
<!-- ... -->
</div>
The AI chef becomes a bottom sheet triggered by a text button inline with the ingredient tools (no floating action button cluttering the screen):
<!-- Ingredient row: Scale / Analyze / Grocery / AI Chef -->
<v-btn color="primary" variant="text" size="small"
prepend-icon="mdi-robot-excited" @click="showAiSheet = true">
AI Chef
</v-btn>
<v-bottom-sheet v-model="showAiSheet">
<!-- full AI interface -->
</v-bottom-sheet>
SEO + Meta Tags
Since the content is now on a real URL, we inject <title> and Open Graph meta tags dynamically on load:
const injectMetaTags = (title: string, summary: string, imageUrl: string) => {
const pageTitle = `${title} | Recipe Finder`;
document.title = pageTitle;
const setMeta = (selector: string, attr: string, value: string) => {
document.querySelector(selector)?.setAttribute(attr, value);
};
setMeta("meta[property='og:title']", 'content', pageTitle);
setMeta("meta[name='twitter:title']", 'content', pageTitle);
if (summary) {
const clean = summary.replace(/<[^>]*>/g, '').trim().slice(0, 155);
setMeta("meta[name='description']", 'content', clean);
setMeta("meta[property='og:description']", 'content', clean);
}
if (imageUrl) {
setMeta("meta[property='og:image']", 'content', imageUrl);
}
};
This is called inside a watch on the recipe computed ref, so it fires on both initial load and when navigating between similar recipes.
Async Components Throughout
Every non-critical UI piece is lazy-loaded:
const ImportToGroceryList = defineAsyncComponent(
() => import('@/components/ImportToGroceryList.vue')
);
const PremiumUpgradeDialog = defineAsyncComponent(
() => import('@/components/PremiumUpgradeDialog.vue')
);
const RecipeScaler = defineAsyncComponent(
() => import('@/components/RecipeScaler.vue')
);
const RecipeEmbedWatermark = defineAsyncComponent(
() => import('@/components/RecipeEmbedWatermark.vue')
);
The main recipe content renders immediately. The grocery dialog, scaler, embed widget, and upgrade dialog only load if the user actually interacts with them.
Auth Guard on Grocery Import
One regression we caught: clicking "Send to Grocery List" while logged out was calling getAccessTokenSilently() and throwing an Auth0 missing refresh token error. Fixed by checking auth state before opening the dialog:
const onDialogToggle = (open: boolean) => {
if (open) {
if (!isAuthenticated.value) {
dialogOpen.value = false;
loginWithRedirect();
return;
}
loadGroceryLists();
}
};
The Result
HomePage.vue Script Size: Reduced from ~650 lines to ~390 lines
Recipe URL: Now fully shareable
Google Indexing: Now fully indexable
Mobile Layout: Upgraded from a fullscreen modal to a native page with a bottom sheet
Auth Crash on Grocery: Fixed
AI on Mobile: Moved from a floating button to an inline trigger
The modal still exists for the recipe list view (quick-peek without leaving the page), but the canonical experience is now a proper page. The code is cleaner, the app is faster to load, and every recipe finally has a real URL.
Built with Vue 3, Vuetify 3, TypeScript, and Tailwind CSS. Auth via Auth0.