DEV Community

Cover image for From Modal to Full Page: How We Refactored a Vue 3 Recipe Detail View
Rusu Ionut
Rusu Ionut

Posted on

From Modal to Full Page: How We Refactored a Vue 3 Recipe Detail View

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.

Demo:
https://recipe-finder.org/recipe/644488-german-rhubarb-cake-with-meringue

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"/>
Enter fullscreen mode Exit fullscreen mode

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' }
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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);
  }
};
Enter fullscreen mode Exit fullscreen mode

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')
);
Enter fullscreen mode Exit fullscreen mode

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();
  }
};
Enter fullscreen mode Exit fullscreen mode

The Result

Metric Before After
HomePage.vue Script Size ~650 lines ~390 lines
Recipe URL Shareable
Google Indexable
Mobile Layout Fullscreen modal Native page with bottom sheet
Auth Crash on Grocery ⚠️ Existed ✅ Fixed
AI on Mobile Floating button Inline trigger → bottom sheet

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.

Top comments (0)