From Modal to Full Page: How We Refactored a Vue 3 Recipe Detail View (and Made It Faster)

From Modal to Full Page: How We Refactored a Vue 3 Recipe Detail View (and Made It Faster)

1 2 20
calendar_todayschedule6 min read

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 =&gt; {
  const id = parseInt(slug.split('-')[0]);
  return isNaN(id) ? null : id;
};</code></pre>

<hr />

<h2>What We Cut From HomePage.vue</h2>

<p>Once the page was independent, we stripped <code>HomePage.vue</code> of everything modal-related. Gone:</p>

<ul>
  <li><code>isRecipeModalOpen</code> ref</li>
  <li><code>selectedRecipeDetails</code> and <code>loadingRecipeDetails</code> state</li>
  <li>The <code>openRecipeModal</code> / <code>closeModal</code> handlers</li>
  <li>The slug-watching <code>watch</code> that re-fetched on URL change</li>
  <li>The <code>&lt;RecipeDetailsModal&gt;</code> import and component registration</li>
  <li>The <code>handleOpenRecipeDetails</code> function passed down through three component layers</li>
</ul>

<p>The result was <code>HomePage.vue</code> shrinking by roughly <strong>40%</strong> in script size. It now does one thing: show the search form and results.</p>

<hr />

<h2>What the Page Layout Looks Like</h2>

<p>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.</p>

<pre><code>&lt;v-row&gt;
  &lt;!-- Left: main content --&gt;
  &lt;v-col cols="12" lg="8"&gt;
    &lt;v-img :src="recipe.image" cover rounded="lg" class="mb-6" /&gt;
    &lt;h1 class="recipe-title"&gt;{{ recipe.title }}&lt;/h1&gt;
    &lt;!-- chips, rating, action buttons, summary, ingredients, instructions, nutrition --&gt;
  &lt;/v-col&gt;

  &lt;!-- Right: sticky sidebar (desktop only) --&gt;
  &lt;v-col cols="12" lg="4" class="d-none d-lg-flex flex-column"&gt;
    &lt;div class="sidebar-sticky"&gt;
      &lt;!-- Recipe Tools card --&gt;
      &lt;!-- AI Cooking Chef card --&gt;
      &lt;!-- Nutrition Snapshot card --&gt;
    &lt;/div&gt;
  &lt;/v-col&gt;
&lt;/v-row&gt;</code></pre>

<p>The sidebar cards use a glass morphism style that matches the rest of the app:</p>

<pre><code>.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;
}</code></pre>

<hr />

<h2>Mobile: Icon Buttons + Bottom Sheet AI</h2>

<p>On mobile, the page can't show a sidebar. We solved this in two ways:</p>

<p><strong>Action buttons</strong> become icon-only on small screens, matching how the modal used to look:</p>

<pre><code>&lt;!-- Mobile: icon-only --&gt;
&lt;div class="d-flex d-sm-none gap-2 mb-6 align-center"&gt;
  &lt;v-btn icon variant="tonal" size="small" @click.stop="handleToggleFavoriteRecipe"&gt;
    &lt;v-icon&gt;{{ isFavorited ? 'mdi-heart' : 'mdi-heart-outline' }}&lt;/v-icon&gt;
  &lt;/v-btn&gt;
  &lt;v-btn icon variant="tonal" size="small" @click="shareRecipe"&gt;
    &lt;v-icon&gt;{{ isCopied ? 'mdi-check' : 'mdi-share-variant' }}&lt;/v-icon&gt;
  &lt;/v-btn&gt;
  &lt;v-btn icon variant="tonal" size="small" @click="printRecipe"&gt;
    &lt;v-icon&gt;mdi-printer&lt;/v-icon&gt;
  &lt;/v-btn&gt;
&lt;/div&gt;

&lt;!-- Desktop: text buttons --&gt;
&lt;div class="d-none d-sm-flex flex-wrap gap-2 mb-6"&gt;
  &lt;v-btn prepend-icon="mdi-heart-outline" variant="tonal" class="page-action-btn"&gt;
    Add to Favorites
  &lt;/v-btn&gt;
  &lt;!-- ... --&gt;
&lt;/div&gt;</code></pre>

<p><strong>The AI chef</strong> becomes a bottom sheet triggered by a text button inline with the ingredient tools (no floating action button cluttering the screen):</p>

<pre><code>&lt;!-- Ingredient row: Scale / Analyze / Grocery / AI Chef --&gt;
&lt;v-btn color="primary" variant="text" size="small"
  prepend-icon="mdi-robot-excited" @click="showAiSheet = true"&gt;
  AI Chef
&lt;/v-btn&gt;

&lt;v-bottom-sheet v-model="showAiSheet"&gt;
  &lt;!-- full AI interface --&gt;
&lt;/v-bottom-sheet&gt;</code></pre>

<hr />

<h2>SEO + Meta Tags</h2>

<p>Since the content is now on a real URL, we inject <code>&lt;title&gt;</code> and Open Graph meta tags dynamically on load:</p>

<pre><code>const injectMetaTags = (title: string, summary: string, imageUrl: string) =&gt; {
  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.

🔥 Join developers growing publicly
Share your knowledge, build in public, and grow your developer presence with a global community.

More Posts

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolio - Apr 1

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7
chevron_left
606 Points23 Badges
Suceava, Romaniarusu-ionut.ro
7Posts
14Comments
16Connections
Fullstack Developer

Related Jobs

View all jobs →

Commenters (This Week)

3 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!