Let's look at the breakdown of both frameworks side-by-side to understand the massive shift in mindset.
Project 1: The Vue.js Approach (Code-Driven Logic)
Step 1: Initialize the Project
npm create vite@latest vue-modal-app -- --template vue
cd vue-modal-app
npm install vue-router@4
Step 2: Configure the Routing Paths (src/router.js)
import { createRouter, createWebHistory } from 'vue-router'
import GalleryView from './views/GalleryView.vue'
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: GalleryView,
children: [
{
path: 'photo/:id',
component: () => import('./components/PhotoModal.vue'),
props: true
}
]
}
]
})
Step 3: Link the Router to the Application
Update src/main.js:
import { createApp } from 'vue'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
Replace src/App.vue:
<template>
<router-view />
</template>
Step 4: Create the Gallery List Page (src/views/GalleryView.vue)
<template>
<div style="padding: 20px; font-family: sans-serif;">
<h1>Vue Photo Gallery</h1>
<div style="display: flex; gap: 20px;">
<div
v-for="photoId in ['sunset', 'ocean', 'mountains']"
:key="photoId"
@click="openPhoto(photoId)"
style="width: 150px; height: 100px; background: #3b82f6; color: white; display: flex; align-items: center; justify-content: center; cursor: pointer; border-radius: 8px;"
>
View {{ photoId }}
</div>
</div>
<!-- Renders our Child Popup component over the grid -->
<router-view v-if="showModal" @close="closeModal" />
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const showModal = ref(false)
// Crucial: The Watcher forces the modal open if an ID exists on hard refresh
watch(
() => route.params.id,
(newId) => {
showModal.value = !!newId
},
{ immediate: true }
)
function openPhoto(id) {
router.push(`/photo/${id}`)
}
function closeModal() {
router.push('/')
}
</script>
Step 5: Create the Popup Modal Component (src/components/PhotoModal.vue)
<template>
<div style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999;">
<div style="background: white; padding: 30px; border-radius: 12px; text-align: center; width: 300px;">
<h2>Vue Modal Layout</h2>
<p style="font-size: 24px; font-weight: bold; text-transform: uppercase; color: #3b82f6;">
🌌 {{ id }}
</p>
<button @click="$emit('close')" style="margin-top: 20px; padding: 8px 16px; cursor: pointer;">Close Overlay</button>
</div>
</div>
</template>
<script setup>
defineProps({ id: String })
defineEmits(['close'])
</script>
Run via npm run dev to view.
Project 2: The Next.js Approach (Folder-Driven Architecture)
Step 1: Initialize the Project
# Configuration flags: TypeScript (Yes), ESLint (No), src/ (No), App Router (Yes)
npx create-next-app@latest next-modal-app
cd next-modal-app
Step 2: Create the File Folders
Manually create the exact folders matching the Hierarchy Map above directly inside your editor's file sidebar to prevent system escape errors.
Step 3: Set up the Main Gallery Page (app/gallery/page.tsx)
import Link from 'next/link';
export default function GalleryPage() {
return (
<div style={{ padding: '20px', fontFamily: 'sans-serif' }}>
<h1>Next.js Photo Gallery</h1>
<div style={{ display: 'flex', gap: '20px' }}>
{['sunset', 'ocean', 'mountains'].map((photoId) => (
<Link
key={photoId}
href={`/gallery/photo/${photoId}`}
style={{
width: '150px',
height: '100px',
background: '#ef4444',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textDecoration: 'none',
borderRadius: '8px'
}}
>
View {photoId}
</Link>
))}
</div>
</div>
);
}
Step 4: Configure the Layout Slots (app/gallery/layout.tsx)
export default function GalleryLayout({
children,
modal
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
return (
<div>
{children}
{modal}
</div>
);
}
Step 5: Create the Slot Fallback (app/gallery/@modal/default.tsx)
export default function DefaultModal() {
// Fixes the unmounted slot mismatch crash by returning a baseline null
return null;
}
Step 6: Create the Intercepted Popup View (app/gallery/@modal/(.)photo/[id]/page.tsx)
'use client';
import { useRouter, useParams } from 'next/navigation';
export default function InterceptedPhotoPopup() {
const router = useRouter();
const params = useParams();
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 9999 }}>
<div style={{ background: 'white', padding: '30px', borderRadius: '12px', textAlign: 'center', width: '300px' }}>
<h2>Next.js Intercepted Popup</h2>
<p style={{ fontSize: '24px', fontWeight: 'bold', textTransform: 'uppercase', color: '#ef4444' }}>
📸 {params.id}
</p>
<button onClick={() => router.back()} style={{ marginTop: '20px', padding: '8px 16px', cursor: 'pointer' }}>
Close Overlay
</button>
</div>
</div>
);
}
Step 7: Create the Standalone Fallback Page (app/gallery/photo/[id]/page.tsx)
import Link from 'next/link';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function StandalonePhotoPage({ params }: PageProps) {
// Modern Next.js rule: params must be unwrapped asynchronously
const { id } = await params;
return (
<div style={{ padding: '40px', fontFamily: 'sans-serif', textAlign: 'center' }}>
<h1>Standalone Full-Screen View</h1>
<div style={{ margin: '40px auto', width: '400px', height: '250px', background: '#ef4444', color: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '32px', borderRadius: '12px' }}>
🌌 {id.toUpperCase()}
</div>
<Link href="/gallery" style={{ color: '#ef4444' }}>← Back to Gallery List</Link>
</div>
);
}
Run via npm run dev and navigate to http://localhost:3000/gallery.
Top comments (0)