Vue 3 Frontend with Production Structure

Build a Vue 3 SPA with production folder structure that calls a real backend API.

Introduction

30 Second Summary

Every day, the same question haunts you: what should I eat? You stare at the options, paralyzed by choice, until hunger makes the decision for you.

In this project, you will build a Vue 3 single-page app that solves this problem. Your app randomly picks a restaurant from your personal list, lets you confirm or re-roll, and keeps a history of every meal.

What You'll Build

Open your browser, click one button, and watch the app pull a random restaurant from your real backend API. Confirm your pick or spin again. Navigate between three pages without the browser ever reloading.

By the end of this project, you'll have:

  • A randomizer page where you click a button, see a restaurant appear with days-since info, and confirm or re-roll your choice.
  • A history timeline and a restaurant management page that let you browse past meals and add new places to your list.
  • A production folder structure with a separated API layer, composables for reusable logic, views, components, and router that mirrors how real companies organize Vue apps.
  • Secret Mission: Add category filtering so you can narrow the randomizer to specific food types.

Are there any prerequisites?

This project assumes you have Bun installed and a working Elysia backend running on localhost:3000 with SQLite. You also need basic HTML, CSS, and JavaScript knowledge.

Before We Start

You have a working backend that handles your food data. Before we build the frontend that brings it to life in the browser, let's lock in exactly what you're creating and why it matters.

Scaffold Vue and Enable CORS

Your Elysia backend is running and serving data, but browsers block requests between different origins by default. A frontend on localhost:5173 cannot talk to a backend on localhost:3000 unless the backend explicitly allows it.

In this step, you will enable CORS on your backend, scaffold a new Vue 3 project with Vite, and create a production-ready folder structure that separates concerns the way real-world apps do.

In this step, get ready to:
  • Enable CORS on your Elysia backend so it accepts frontend requests.
  • Scaffold a Vue 3 + Vite project and install Vue Router.
  • Create the production folder structure with separated api, composables, views, components, and router directories.
Add CORS to your Elysia backend

Cross-origin requests are blocked by default in browsers. Your Vue frontend will run on a different port than the backend, so you need to install the @elysia/cors plugin to allow those requests through.

  • In your backend terminal, stop the running server with Ctrl+C.
  • Install the CORS plugin by running:
bun add @elysia/cors

You should see the package added to your package.json dependencies.

  • Open your backend entry file (the file where new Elysia() is defined).
  • Add the CORS import at the top of the file:
import { cors } from '@elysia/cors'
  • Add .use(cors()) to your Elysia chain, before your existing routes:
new Elysia()
  .use(cors())
  // ... your existing routes below

What does this do?

The .use(cors()) call tells Elysia to include the proper Access-Control-Allow-Origin headers in every response. By default, it allows requests from any origin, which is fine for local development.

  • Save the file and restart your backend by running:
bun run src/index.ts

You should see your server start up again on port 3000 with no errors.

Backend not starting?

  • Check that the import statement uses @elysia/cors (with the @elysia/ prefix).
  • Make sure .use(cors()) appears before your route definitions, not after them.
  • help me fix my Elysia CORS setup
Scaffold Vue 3 + Vite project and install dependencies

Now you will create the Vue frontend project as a separate folder alongside your backend. Vite handles the build tooling and gives you instant hot-reload during development.

  • Open a new terminal window (keep your backend running in the other one).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold the Vue project by running:
bun create vite wannee-gin-arai --template vue

What does this command do?

  • bun create vite invokes the Vite scaffolding tool through Bun.
  • wannee-gin-arai is the folder name for your new project.
  • --template vue tells Vite to use the Vue 3 starter template (no TypeScript, no extra tooling).
  • Move into the new project folder and install dependencies by running these commands:
cd wannee-gin-arai
bun install

You should see Bun download and link the project dependencies.

  • Install Vue Router by running:
bun add vue-router

You should see vue-router added to your package.json dependencies.

Why Vue Router?

Vue Router enables client-side navigation between pages without a full browser reload. Your app will have three pages (randomizer, history, restaurant management), and Vue Router lets the user switch between them instantly.

  • Confirm the scaffold created the expected files by running:
ls src/

You should see App.vue, main.js, style.css, assets/, and components/ listed.

Scaffold command failed?

  • Make sure you ran cd ~/Desktop first so the project folder is created on your Desktop.
  • Check that Bun is installed correctly by running bun --version.
  • help me fix my Vite scaffold command
Set up production folder structure

The default scaffold puts everything in a flat src/ directory. Production apps separate HTTP logic, reusable composables, page-level views, shared components, and routing into distinct folders.

  • Remove the default scaffold files you do not need by running:
rm src/style.css
rm src/components/HelloWorld.vue
rm -rf src/assets
  • Create the production directories by running:
mkdir -p src/api src/composables src/views src/router
  • Confirm the new directories exist by running:
ls src/

You should see api/, components/, composables/, router/, views/, App.vue, and main.js listed.

  • Create empty placeholder files in each folder by running:
touch src/api/client.js src/api/menuApi.js
touch src/composables/useRandom.js
touch src/components/MenuCard.vue
touch src/views/RandomView.vue src/views/HistoryView.vue src/views/MenuListView.vue
touch src/router/index.js

What is this folder structure for?

  • api/ holds all HTTP fetching logic. Components never call fetch directly.
  • composables/ holds reusable logic with Vue reactivity (state + functions bundled together).
  • views/ holds page-level components, one per route.
  • components/ holds small, reusable UI pieces shared across pages.
  • router/ holds navigation configuration.

Before you run the dev server, what do you think will happen? The main.js still imports style.css which you just deleted.

  • Open src/main.js in your code editor and remove the line that imports style.css. The file should look like this:
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')
  • Also open src/App.vue and replace its contents with a minimal placeholder:
<script setup>
</script>

<template>
  <div>
    <h1>wannee-gin-arai</h1>
  </div>
</template>
  • Save both files.

Now predict: if you start the dev server, do you expect to see an error or a working page?

  • Start the Vite dev server by running:
bun run dev

You should see output showing the local server at http://localhost:5173. Open that URL in your browser and you will see the text "wannee-gin-arai" on the page.

Dev server showing errors?

  • If you see a "failed to resolve import" error for ./style.css, make sure you removed that import line from src/main.js.
  • If you see an error about HelloWorld.vue, make sure src/App.vue no longer imports it.
  • help me fix my Vite dev server errors

✔️ Awesome, I've got everything!

Great. Make sure you have saved both src/main.js and src/App.vue and that the dev server is running without errors.

ⓧ I'd like to double check the full code

Here is what each edited file should look like. The other files in your folder structure are all empty placeholders at this point.

src/main.js:

import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

src/App.vue:

<script setup>
</script>

<template>
  <div>
    <h1>wannee-gin-arai</h1>
  </div>
</template>

Your src/ folder should contain these directories and files:

src/
  api/client.js
  api/menuApi.js
  composables/useRandom.js
  components/MenuCard.vue
  views/RandomView.vue
  views/HistoryView.vue
  views/MenuListView.vue
  router/index.js
  App.vue
  main.js

Your environment is ready. The backend accepts cross-origin requests and your Vue project has a clean production structure. Next up, you will build the API layer and pull your first live data from the backend.

Build the API Layer and Test Live Data

Your backend is running and your folder structure is ready. But before building any UI, you need a clean way for your components to talk to the backend.

In production apps, components never call fetch directly. Instead, all HTTP logic lives in a dedicated API layer. This keeps your components focused on displaying data, not on constructing URLs or parsing responses.

In this step, get ready to:
  • Build a reusable API client that handles base URLs, headers, and errors.
  • Create API functions for every backend endpoint.
  • Wire up a minimal Vue page that fetches live data from your backend.
Create the API client

The API client is a single wrapper function that every other API call flows through. It prepends your backend URL, sets JSON headers, checks for errors, and returns parsed data.

  • Open src/api/client.js in your editor and paste this code:
// Base URL pointing to your Elysia backend
const API_BASE_URL = 'http://localhost:3000'

// Reusable fetch wrapper for all API calls
export async function fetchApi(endpoint, options = {}) {
  const url = `${API_BASE_URL}${endpoint}`
  const config = {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  }

  const response = await fetch(url, config)

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${response.statusText}`)
  }

  return response.json()
}

What does this code do?

API_BASE_URL stores your backend address in one place. If you later deploy to a different domain, you only change this single line.

fetchApi builds the full URL from the endpoint you pass in, merges your options with default JSON headers, and throws an error if the response status is not OK. Every API function you write next will call this instead of raw fetch.

  • Save client.js.
Create menu API functions

Now you need one function per backend endpoint. Each function calls fetchApi with the right path and HTTP method, so the rest of your app never thinks about URLs or request bodies.

  • Open src/api/menuApi.js and paste this code:
import { fetchApi } from './client.js'

// Get a random restaurant from the backend
export function getRandomMenu() {
  return fetchApi('/api/random')
}

// Confirm a restaurant choice by ID
export function confirmMenu(id) {
  return fetchApi('/api/confirm', {
    method: 'POST',
    body: JSON.stringify({ id }),
  })
}

// Fetch all eating history
export function getHistory() {
  return fetchApi('/api/history')
}

// Fetch the full restaurant list
export function getMenuList() {
  return fetchApi('/api/menu')
}

// Add a new restaurant
export function addMenu(data) {
  return fetchApi('/api/menu', {
    method: 'POST',
    body: JSON.stringify(data),
  })
}

What does this code do?

Each function maps directly to one of your Elysia backend endpoints. getRandomMenu hits GET /api/random, confirmMenu sends a POST to /api/confirm, and so on.

Notice that none of these functions use async/await. They simply return the Promise from fetchApi. The calling code decides when to await the result.

  • Save menuApi.js.

Before wiring these into a Vue component, you can test that the API layer works right now from your browser console.

  • Open your browser at http://localhost:5173.
  • Open DevTools with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) to jump straight to the Console tab.
  • Paste this line into the console and press Enter:
import('/src/api/menuApi.js').then(m => m.getMenuList()).then(console.log)

You should see your restaurant list logged as an array. This proves your API client can reach the backend and parse the response correctly.

Seeing an error in the console?

  • If you see a CORS or network error, make sure your Elysia backend is still running on localhost:3000 with the cors() plugin enabled.
  • If you see a module not found error, double-check that client.js is saved inside src/api/ and that menuApi.js imports from './client.js' with the relative path.
  • If the array is empty, that just means you have no restaurants in your database yet. The connection still works.

Help me debug my API layer test.

✔️ Awesome, I've got everything!

Great. Your API layer is proven. Every component you build from here can import these functions and never worry about raw fetch calls.

ⓧ I'd like to double check the full code

Here is src/api/client.js in full:

const API_BASE_URL = 'http://localhost:3000'

export async function fetchApi(endpoint, options = {}) {
  const url = `${API_BASE_URL}${endpoint}`
  const config = {
    headers: {
      'Content-Type': 'application/json',
    },
    ...options,
  }

  const response = await fetch(url, config)

  if (!response.ok) {
    throw new Error(`API error: ${response.status} ${response.statusText}`)
  }

  return response.json()
}

And here is src/api/menuApi.js in full:

import { fetchApi } from './client.js'

export function getRandomMenu() {
  return fetchApi('/api/random')
}

export function confirmMenu(id) {
  return fetchApi('/api/confirm', {
    method: 'POST',
    body: JSON.stringify({ id }),
  })
}

export function getHistory() {
  return fetchApi('/api/history')
}

export function getMenuList() {
  return fetchApi('/api/menu')
}

export function addMenu(data) {
  return fetchApi('/api/menu', {
    method: 'POST',
    body: JSON.stringify(data),
  })
}
Wire up a minimal view to test the API on page load

The console test proved the API works. Now you will connect it to a Vue component so the data fetches automatically when the page loads. This requires four small files: a router, the app entry point, a root component, and the view itself.

  • Open src/router/index.js and paste this routing configuration:
import { createRouter, createWebHistory } from 'vue-router'
import RandomView from '../views/RandomView.vue'

// Create the router with HTML5 history mode
const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'random',
      component: RandomView
    }
  ]
})

export default router

What does this code do?

createRouter from Vue Router sets up client-side navigation. createWebHistory() gives you clean URLs without hash fragments.

The single route maps / (your homepage) to RandomView. You will add more routes in a later step.

  • Save router/index.js.
  • Open src/main.js and replace its contents with this:
import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index.js'

// Create the app, register the router, and mount
const app = createApp(App)
app.use(router)
app.mount('#app')

What does this code do?

createApp initializes your Vue 3 application. app.use(router) registers the router so every component can use navigation features.

app.mount('#app') attaches the entire app to the DOM element with id app in your index.html.

  • Save main.js.
  • Open src/App.vue and replace its contents with this minimal shell:
<script setup>
import { RouterView } from 'vue-router'
</script>

<template>
  <div class="app">
    <RouterView />
  </div>
</template>
  • Save App.vue.
  • Open src/views/RandomView.vue and paste this minimal test component:
<script setup>
import { onMounted } from 'vue'
import { getMenuList } from '../api/menuApi.js'

// Fetch restaurant list on page load and log it
onMounted(async () => {
  const data = await getMenuList()
  console.log('Restaurant list:', data)
})
</script>

<template>
  <div>
    <h1>วันนี้กินไร?</h1>
    <p>Check the console for API data.</p>
  </div>
</template>

What does this code do?

This is your first Vue 3 component using <script setup>. Everything you declare at the top level is automatically available in the template.

onMounted is a lifecycle hook that runs once the component appears on screen. Here it calls getMenuList() from your API layer and logs the result. This proves the full chain: Vue component to API layer to Elysia backend and back.

  • Save RandomView.vue.

Before you check the browser, what do you expect to see in the console when the page loads?

  • Refresh your browser at http://localhost:5173.
  • Open DevTools with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).

You should see the heading "วันนี้กินไร?" on the page and your restaurant list logged as an array in the console. The full data flow is working: Vue mounts the component, calls the API layer, which hits your Elysia backend through the fetchApi wrapper.

Page is blank or console shows errors?

  • Check that App.vue contains <RouterView /> in its template. Without it, the router has nowhere to render the view.
  • Confirm the import path in RandomView.vue says '../api/menuApi.js' with two dots. A single dot would look in the wrong folder.
  • If you see a CORS error, switch to your backend terminal and confirm Elysia is still running.

Help me debug my Vue Router setup and API connection.

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: router/index.js, main.js, App.vue, and RandomView.vue.

ⓧ I'd like to double check the full code

Here is src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import RandomView from '../views/RandomView.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'random',
      component: RandomView
    }
  ]
})

export default router

Here is src/main.js:

import { createApp } from 'vue'
import App from './App.vue'
import router from './router/index.js'

const app = createApp(App)
app.use(router)
app.mount('#app')

Here is src/App.vue:

<script setup>
import { RouterView } from 'vue-router'
</script>

<template>
  <div class="app">
    <RouterView />
  </div>
</template>

Your API layer is solid and proven with live data. Next up, you will build the randomizer page with buttons, state management, and the full spin-confirm-reroll flow.

Build the Randomizer Page

Your API layer is wired up and the dev server is running. Now it is time to build the core feature: the randomizer page that pulls a restaurant from your backend and displays it.

Displaying a random result proves the full frontend-to-backend flow works end to end. You will learn how ref() manages reactive state, how template binding renders that state, and how v-if shows or hides elements based on conditions.

In this step, get ready to:
  • Create a reusable MenuCard component with props.
  • Build the full random/confirm/re-roll flow in RandomView.
  • Extract reusable logic into a composable for production structure.
Create the MenuCard component

The randomizer will display a restaurant name in a styled card. In production Vue apps, you build reusable UI pieces as components with defined props so any page can render them.

  • Open src/components/MenuCard.vue and replace the empty file with this script and template:
<script setup>
defineProps({
  name: {
    type: String,
    required: true
  },
  daysSince: {
    type: Number,
    default: null
  }
})
</script>

<template>
  <div class="menu-card">
    <p class="restaurant-name">{{ name }}</p>
    <p v-if="daysSince !== null" class="days-ago">
      ไม่ได้กิน {{ daysSince }} วันแล้ว
    </p>
    <p v-else class="days-ago">ยังไม่เคยกิน!</p>
  </div>
</template>

What does this code do?

defineProps is a Vue compiler macro that declares what data a parent component can pass in. Here, name is required (the restaurant name) and daysSince defaults to null if not provided.

The v-if/v-else pair shows different text depending on whether the learner has eaten there before.

  • Add the scoped styles below the </template> tag:
<style scoped>
.menu-card {
  background: white;
  border-radius: 16px;
  padding: 24px;
  text-align: center;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  margin: 16px 0;
}

.restaurant-name {
  font-size: 24px;
  font-weight: bold;
  margin-bottom: 8px;
  color: #e65100;
}

.days-ago {
  color: #888;
  font-size: 14px;
}
</style>

What does scoped mean?

The scoped attribute limits these CSS rules to this component only. Other components cannot accidentally inherit these styles, which prevents naming collisions across your app.

  • Save MenuCard.vue.
  • You will see this card in action once RandomView renders it in the next substep.
Build the full random flow in RandomView

Now you will replace the minimal placeholder in RandomView with the actual randomizer logic. Vue's ref() function creates reactive variables that automatically update the page when their value changes.

  • Open src/views/RandomView.vue and replace all existing content with this script section:
<script setup>
import { ref } from 'vue'
import { getRandomMenu, confirmMenu } from '../api/menuApi.js'
import MenuCard from '../components/MenuCard.vue'

// Reactive state for the randomizer
const currentResult = ref(null)
const isLoading = ref(false)
const isConfirmed = ref(false)

async function rollRandom() {
  isLoading.value = true
  isConfirmed.value = false
  try {
    currentResult.value = await getRandomMenu()
  } catch (error) {
    console.error('Failed to get random menu:', error)
  } finally {
    isLoading.value = false
  }
}

What is ref()?

ref(null) creates a reactive wrapper. In your script, you read and write its value through .value. In the template, Vue automatically unwraps it so you just use the variable name directly.

The rollRandom function sets loading to true, calls the API, stores the result, then sets loading to false. The try/finally pattern ensures loading always resets even if the request fails.

  • Add these two functions below rollRandom, still inside the <script setup> block:
async function confirmChoice() {
  if (!currentResult.value) return
  try {
    await confirmMenu(currentResult.value.id)
    isConfirmed.value = true
  } catch (error) {
    console.error('Failed to confirm:', error)
  }
}

function reset() {
  currentResult.value = null
  isConfirmed.value = false
}
</script>

What do these functions do?

  • confirmChoice sends the chosen restaurant's ID to your backend's confirm endpoint, then flips isConfirmed to true so the template can show a success message.
  • reset clears the state so the learner can start a fresh roll.
  • Add this template section below the closing </script> tag:
<template>
  <div class="random-page">
    <h1 class="title">วันนี้กินไร?</h1>

    <button
      class="random-btn"
      @click="rollRandom"
      :disabled="isLoading"
    >
      {{ isLoading ? 'กำลังสุ่ม...' : 'สุ่มเมนูวันนี้' }}
    </button>

    <div v-if="currentResult && !isConfirmed">
      <MenuCard
        :name="currentResult.name"
        :days-since="currentResult.daysSince"
      />
      <div class="action-buttons">
        <button class="confirm-btn" @click="confirmChoice">
          กินอันนี้แหละ
        </button>
        <button class="reroll-btn" @click="rollRandom">
          สุ่มใหม่
        </button>
      </div>
    </div>

    <div v-if="isConfirmed" class="confirmed-card">
      <p class="confirmed-text">บันทึกแล้ว!</p>
      <p class="confirmed-name">{{ currentResult.name }}</p>
      <button class="random-btn" @click="reset" style="margin-top: 16px;">
        สุ่มอีกรอบ
      </button>
    </div>
  </div>
</template>

How does v-if control what shows?

  • v-if="currentResult && !isConfirmed" only renders the MenuCard and action buttons when there IS a result but the learner has NOT confirmed yet.
  • v-if="isConfirmed" swaps to the confirmation card after the learner clicks confirm.
  • @click="rollRandom" binds the button click to your function. :disabled="isLoading" greys it out while the API call is in progress.

Before you click the button, what do you expect to happen when the API call completes?

  • Save RandomView.vue and refresh your browser at http://localhost:5173.
  • Click the สุ่มเมนูวันนี้ button.

You should see a restaurant name appear on the page. The styling is not applied yet, so it will look plain. The important thing is that the data flows from your backend all the way to the screen.

Button does nothing or you see an error?

  • Open the browser console (F12). If you see a CORS error, make sure your Elysia backend is running with the cors() plugin and that it was restarted after adding the plugin.
  • If you see a network error or 404, confirm your backend is running on localhost:3000 and has the /api/random endpoint.
  • If the button text changes to 'กำลังสุ่ม...' but nothing appears, check that your backend has at least one restaurant in the database.

Help me debug why my random button is not fetching data from the backend.

  • Now add styles to make the page look polished. Add this <style scoped> section below the </template> tag:
<style scoped>
.random-page {
  text-align: center;
  padding-top: 32px;
}

.title {
  font-size: 28px;
  margin-bottom: 32px;
  color: #e65100;
}

.random-btn {
  background: #e65100;
  color: white;
  border: none;
  padding: 16px 32px;
  font-size: 18px;
  border-radius: 12px;
  cursor: pointer;
  transition: transform 0.1s, background 0.2s;
}

.random-btn:hover {
  background: #bf360c;
}

.random-btn:active {
  transform: scale(0.95);
}

.random-btn:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>
  • Save and refresh your browser.
  • You should see an orange rounded button centered on the page with the title styled above it.
  • Add these rules inside the <style scoped> block, above the closing </style> tag:
.action-buttons {
  display: flex;
  gap: 12px;
  justify-content: center;
  margin-top: 20px;
}

.confirm-btn {
  background: #2e7d32;
  color: white;
  border: none;
  padding: 12px 24px;
  font-size: 16px;
  border-radius: 8px;
  cursor: pointer;
}

.confirm-btn:hover {
  background: #1b5e20;
}

.reroll-btn {
  background: #f57c00;
  color: white;
  border: none;
  padding: 12px 24px;
  font-size: 16px;
  border-radius: 8px;
  cursor: pointer;
}

.reroll-btn:hover {
  background: #e65100;
}
  • Save and click สุ่มเมนูวันนี้ again.
  • You should see the green กินอันนี้แหละ and orange สุ่มใหม่ buttons styled below the restaurant card.
  • Add the final style rules for the confirmation card, still inside the <style scoped> block:
.confirmed-card {
  margin-top: 32px;
  background: #e8f5e9;
  border-radius: 16px;
  padding: 24px;
}

.confirmed-text {
  font-size: 20px;
  color: #2e7d32;
  font-weight: bold;
  margin-bottom: 8px;
}

.confirmed-name {
  font-size: 18px;
  color: #333;
}
  • Save RandomView.vue.
  • Click สุ่มเมนูวันนี้, then click กินอันนี้แหละ.
  • You should see a green confirmation card saying 'บันทึกแล้ว!' with the restaurant name below it.
Extract the useRandom composable

The randomizer works, but all the state and logic lives directly inside the view component. In production Vue apps, you extract reusable logic into composables. These are functions prefixed with use that return reactive refs and methods for any component to consume.

  • Open src/composables/useRandom.js and replace the empty file with the following:
import { ref } from 'vue'
import { getRandomMenu, confirmMenu } from '../api/menuApi.js'

export function useRandom() {
  const currentResult = ref(null)
  const isLoading = ref(false)
  const isConfirmed = ref(false)

  async function rollRandom() {
    isLoading.value = true
    isConfirmed.value = false
    try {
      currentResult.value = await getRandomMenu()
    } catch (error) {
      console.error('Failed to get random menu:', error)
    } finally {
      isLoading.value = false
    }
  }
  • Continue adding the rest of the composable below rollRandom:
  async function confirmChoice() {
    if (!currentResult.value) return
    try {
      await confirmMenu(currentResult.value.id)
      isConfirmed.value = true
    } catch (error) {
      console.error('Failed to confirm:', error)
    }
  }

  function reset() {
    currentResult.value = null
    isConfirmed.value = false
  }

  return { currentResult, isLoading, isConfirmed, rollRandom, confirmChoice, reset }
}

Why extract to a composable?

The composable owns all randomizer state and logic. Your view component becomes a thin shell that only handles layout and wiring. This separation means you could reuse useRandom() in a different page or test it independently without mounting a component.

The return object exposes refs and functions by name. Any component that calls useRandom() can destructure exactly what it needs.

  • Save useRandom.js.

Now refactor RandomView to use the composable instead of inline logic.

  • In src/views/RandomView.vue, find the entire <script setup> block.
  • Replace the full script section with this slim version:
<script setup>
import { useRandom } from '../composables/useRandom.js'
import MenuCard from '../components/MenuCard.vue'

const { currentResult, isLoading, isConfirmed, rollRandom, confirmChoice, reset } = useRandom()
</script>

What changed?

The entire script shrinks to 3 lines. All state and functions now come from useRandom() via destructuring. The template stays exactly the same because it references the same variable names.

Do you think the app will behave any differently after this refactor?

  • Save RandomView.vue and refresh your browser.
  • Click สุ่มเมนูวันนี้ and see a restaurant appear.
  • Click กินอันนี้แหละ and see the confirmation message.
  • Click สุ่มใหม่ to get a different result.

The app behaves identically. The logic moved to a composable but the user experience is unchanged. This proves the extraction was clean.

Something broke after the refactor?

  • Check that the import path in RandomView says '../composables/useRandom.js'. A wrong relative path causes a module-not-found error.
  • Make sure the destructured names match exactly: currentResult, isLoading, isConfirmed, rollRandom, confirmChoice, reset. A typo here means the template references an undefined variable.
  • Confirm that useRandom.js exports the function with export function useRandom() and not a default export.

Help me debug my useRandom composable refactor.

✔️ Awesome, I've got everything!

Great. Confirm you have saved both useRandom.js and RandomView.vue.

ⓧ I'd like to double check the full code

Scroll up and confirm you have each piece in place, in this order:

  • MenuCard.vue: defineProps with name and daysSince, template with v-if/v-else for days-ago text, scoped styles for .menu-card, .restaurant-name, .days-ago.
  • useRandom.js: exports useRandom() with three refs (currentResult, isLoading, isConfirmed), rollRandom, confirmChoice, reset, and returns them all.
  • RandomView.vue: slim script importing useRandom and MenuCard, template with random button, v-if MenuCard block, confirm/reroll buttons, confirmed card, plus all scoped styles.

Your randomizer page is fully functional with a production-ready composable pattern. Next up, you will add Vue Router navigation and build the history page so you can browse past meals without reloading the app.

Add Vue Router and the History Page

Your randomizer page works, but your app only has one view. A single-page app with only one view is just a web page.

Without client-side routing you cannot switch between features without a full page reload. In this step, you'll add Vue Router so your app becomes a real SPA with three pages that swap instantly.

In this step, get ready to:
  • Configure Vue Router with lazy-loaded routes for code splitting.
  • Add a navigation bar to App.vue with RouterLink elements.
  • Build the HistoryView page that fetches and displays past meals.
Configure full router with lazy-loaded routes

Right now your router only knows about the / route. You need to register /history and /menu so Vue Router can switch between all three pages.

  • Open src/router/index.js and replace the entire contents with this:
import { createRouter, createWebHistory } from 'vue-router'
import RandomView from '../views/RandomView.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'random',
      component: RandomView
    },
    {
      path: '/history',
      name: 'history',
      component: () => import('../views/HistoryView.vue')
    },
    {
      path: '/menu',
      name: 'menu',
      component: () => import('../views/MenuListView.vue')
    }
  ]
})

export default router

What does this code do?

RandomView stays eager-loaded with a direct import because it's your landing page and always needed immediately.

The other two routes use () => import(...) which is called lazy loading. Vite splits these into separate files so the browser only downloads them when the learner navigates there. This is the recommended pattern for code splitting in production Vue apps.

  • Save src/router/index.js.

Seeing a blank page?

Make sure each route object has all three keys: path, name, and component. A missing comma between route objects is the most common typo here.

Help me debug my Vue Router config.

Add navigation and RouterView to App.vue

Your router knows about three pages, but there is no way for the user to navigate between them. You need a nav bar with links and a spot where each page renders.

  • Open src/App.vue and replace the entire file contents with this script and template:
<script setup>
import { RouterLink, RouterView } from 'vue-router'
</script>

<template>
  <div class="app">
    <nav class="nav-bar">
      <RouterLink to="/" class="nav-link">สุ่มเมนู</RouterLink>
      <RouterLink to="/history" class="nav-link">ประวัติ</RouterLink>
      <RouterLink to="/menu" class="nav-link">จัดการร้าน</RouterLink>
    </nav>
    <main class="content">
      <RouterView />
    </main>
  </div>
</template>

What does this code do?

RouterLink renders anchor tags that navigate without a page reload. Vue Router intercepts the click and swaps the component inside RouterView instead.

Vue Router automatically adds a router-link-active class to whichever link matches the current route. You will style that class shortly to highlight the active page.

  • Save src/App.vue and refresh your browser at http://localhost:5173.
  • You should see three unstyled text links at the top of the page. Click each one and confirm the URL changes without a full page reload.

The links work, but they look plain. Now add the global styles that give the app its layout and styled navigation.

  • Add a <style> block below the </template> tag with these global resets and layout rules:
<style>
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  background: #f5f5f5;
  color: #333;
}

.app {
  max-width: 480px;
  margin: 0 auto;
  min-height: 100vh;
}

.nav-bar {
  display: flex;
  background: #fff;
  border-bottom: 1px solid #e0e0e0;
  padding: 12px 16px;
  gap: 16px;
  position: sticky;
  top: 0;
  z-index: 10;
}
</style>

Why no scoped attribute on this style tag?

These styles apply globally because they reset browser defaults and set up the full page layout. Scoped styles only affect the current component, but body and the CSS reset need to reach the entire document.

  • Save and refresh your browser. You should see a light gray background, the page content centered in a narrow column, and a white nav bar stuck to the top.
  • Now add the nav link and content area styles inside the same <style> block, just before the closing </style> tag:
.nav-link {
  text-decoration: none;
  color: #666;
  font-weight: 500;
  padding: 8px 12px;
  border-radius: 8px;
  transition: background 0.2s;
}

.nav-link:hover {
  background: #f0f0f0;
}

.nav-link.router-link-active {
  color: #e65100;
  background: #fff3e0;
}

.content {
  padding: 24px 16px;
}

What does router-link-active do?

Vue Router automatically adds the router-link-active class to the RouterLink that matches the current route. Your CSS turns that link orange with a warm background so the user always knows which page they are on.

  • Save src/App.vue and refresh.
  • Your nav bar links should now look like rounded pills. The active link (สุ่มเมนู on /) shows orange text on a peach background.
  • Click ประวัติ and watch the highlight move to that link without a page reload.

Nav links not styled?

Confirm your <style> tag does NOT have a scoped attribute. If it says <style scoped>, remove scoped so the styles apply globally.

Help me fix my App.vue nav bar styles.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/App.vue with all three sections: <script setup>, <template>, and <style>.

ⓧ I'd like to double check the full code

Your src/App.vue should have these sections in order:

  • A <script setup> block importing RouterLink and RouterView from vue-router.
  • A <template> with a .app div containing a <nav> with three RouterLink elements (to /, /history, /menu) and a <main> wrapping <RouterView />.
  • A <style> block (no scoped) with global resets, .app, .nav-bar, .nav-link (including :hover and .router-link-active), and .content rules.

Your src/router/index.js should look like this:

import { createRouter, createWebHistory } from 'vue-router'
import RandomView from '../views/RandomView.vue'

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/',
      name: 'random',
      component: RandomView
    },
    {
      path: '/history',
      name: 'history',
      component: () => import('../views/HistoryView.vue')
    },
    {
      path: '/menu',
      name: 'menu',
      component: () => import('../views/MenuListView.vue')
    }
  ]
})

export default router
Build the HistoryView page

Clicking ประวัติ in the nav shows a blank page because HistoryView.vue is still empty. This view needs to fetch the eating history from your API and display each entry with its date.

  • Open src/views/HistoryView.vue and replace its contents with this script block:
<script setup>
import { ref, onMounted } from 'vue'
import { getHistory } from '../api/menuApi.js'

// Reactive state for the history list and loading indicator
const history = ref([])
const isLoading = ref(true)

// Fetch history from the API when the component first renders
onMounted(async () => {
  try {
    history.value = await getHistory()
  } catch (error) {
    console.error('Failed to fetch history:', error)
  } finally {
    isLoading.value = false
  }
})

// Format a date string into Thai locale display
function formatDate(dateString) {
  return new Date(dateString).toLocaleDateString('th-TH', {
    year: 'numeric',
    month: 'short',
    day: 'numeric',
  })
}
</script>

What does this code do?

  • onMounted is a lifecycle hook that runs once when the component appears on screen. It calls your getHistory() API function and stores the result in the history ref.
  • isLoading starts as true and flips to false after the fetch completes. The template uses this to show a loading message while waiting.
  • formatDate converts raw date strings into human-readable Thai locale dates.
  • Now add the template block directly below the closing </script> tag:
<template>
  <div class="history-page">
    <h1 class="title">ประวัติการกิน</h1>
    <p v-if="isLoading" class="loading">กำลังโหลด...</p>
    <p v-else-if="history.length === 0" class="empty">ยังไม่มีประวัติ ไปสุ่มเมนูกันก่อน!</p>
    <ul v-else class="history-list">
      <li v-for="item in history" :key="item.id" class="history-item">
        <span class="item-name">{{ item.name }}</span>
        <span class="item-date">{{ formatDate(item.date) }}</span>
      </li>
    </ul>
  </div>
</template>

What does v-if, v-else-if, and v-else do here?

These directives create conditional rendering. Only one of the three blocks appears at a time depending on the state: a loading message while fetching, an empty-state message if no history exists, or the full list when data arrives.

v-for loops through the history array and renders one <li> per entry. The :key attribute helps Vue efficiently update the list when data changes.

Before you run this, what do you think the page will show? If you have not confirmed any restaurants on the random page yet, the history will be empty.

  • Save src/views/HistoryView.vue.
  • In your browser, click ประวัติ in the nav bar.
  • If you have previously confirmed a meal, you should see a list of restaurant names with dates in Thai format. If not, you should see the empty state message: ยังไม่มีประวัติ ไปสุ่มเมนูกันก่อน!

Test the full flow

Go back to สุ่มเมนู, roll a restaurant, click กินอันนี้แหละ to confirm it, then navigate to ประวัติ. Your confirmed restaurant should now appear in the history list.

🙋‍♀️ History page shows nothing?

Open your browser's developer console (F12) and check for errors. If you see a CORS or network error, make sure your Elysia backend is still running on localhost:3000.

If the console shows Failed to fetch history, confirm that your backend has a GET /api/history endpoint returning an array.

Help me debug my HistoryView not loading data.

✔️ Awesome, I've got everything!

Great. Confirm you have saved src/views/HistoryView.vue with both the <script setup> and <template> blocks.

ⓧ I'd like to double check the full code

Your src/views/HistoryView.vue should have these sections in order:

  • A <script setup> block importing ref and onMounted from vue, and getHistory from ../api/menuApi.js.
  • Two refs: history (starts as []) and isLoading (starts as true).
  • An onMounted that calls getHistory() and stores results in history.value.
  • A formatDate function using toLocaleDateString('th-TH', ...).
  • A <template> with v-if/v-else-if/v-else for loading, empty, and list states. The list uses v-for with :key="item.id".

Your app is now a proper SPA with instant page navigation and a working history view. Next up, you will build the restaurant management page to complete the CRUD cycle.

Build the Restaurant Management Page

Your app now has multi-page navigation and a working history view. Users can randomize, confirm, and browse past meals.

There is one gap left. Users cannot manage which restaurants are in the pool. Without a management page, the only way to add restaurants is through the backend directly. In this step, you will build a full management page that teaches form handling with v-model, list rendering with v-for, and the composable pattern applied to a second feature.

In this step, get ready to:
  • Display all restaurants from the API using v-for.
  • Add a form to submit new restaurants using v-model.
  • Apply the composable pattern to a second feature with useMenu.
Build MenuListView with v-for list

You already learned the composable pattern with useRandom in the previous step. Instead of writing inline logic and refactoring later, you will apply that same pattern from the start here.

  • Create the src/composables/useMenu.js file and paste the following code:
import { ref } from 'vue'
import { getMenuList, addMenu } from '../api/menuApi.js'

export function useMenu() {
  const menuList = ref([])
  const isLoading = ref(false)

  async function fetchMenuList() {
    isLoading.value = true
    try {
      menuList.value = await getMenuList()
    } catch (error) {
      console.error('Failed to fetch menu list:', error)
    } finally {
      isLoading.value = false
    }
  }

  async function addNewMenu(name) {
    try {
      await addMenu({ name })
      await fetchMenuList()
    } catch (error) {
      console.error('Failed to add menu:', error)
    }
  }

  return { menuList, isLoading, fetchMenuList, addNewMenu }
}

What does this code do?

This composable follows the exact same structure as useRandom. It wraps API calls in async functions and stores results in reactive ref() values.

  • fetchMenuList() calls the getMenuList API function and stores the result in menuList.
  • addNewMenu(name) sends the new restaurant to the backend, then calls fetchMenuList() again to refresh the list automatically.
  • The composable returns everything the view needs as a plain object for destructuring.
  • Save src/composables/useMenu.js.

Now build the view that uses this composable. The script section sets up all the logic the template will need.

  • Open src/views/MenuListView.vue (the empty placeholder) and replace its contents with this script setup:
<script setup>
import { ref, onMounted } from 'vue'
import { useMenu } from '../composables/useMenu.js'

const { menuList, isLoading, fetchMenuList, addNewMenu } = useMenu()
const newName = ref('')

onMounted(() => {
  fetchMenuList()
})

async function handleSubmit() {
  if (!newName.value.trim()) return
  await addNewMenu(newName.value.trim())
  newName.value = ''
}
</script>

What does this script do?

  • The destructured { menuList, isLoading, fetchMenuList, addNewMenu } pulls everything from the composable into the component scope.
  • newName is a local ref that will hold the form input value via v-model.
  • onMounted triggers fetchMenuList() as soon as the page loads.
  • handleSubmit() guards against empty strings, calls the composable, then clears the input.

Next, add the template below the closing </script> tag. This template renders the restaurant list using v-for.

  • Add this template section below </script> in MenuListView.vue:
<template>
  <div class="menu-page">
    <h1 class="title">จัดการร้านอาหาร</h1>

    <form class="add-form" @submit.prevent="handleSubmit">
      <input
        v-model="newName"
        type="text"
        placeholder="ชื่อร้าน เช่น ส้มตำป้าแดง"
        class="input"
      />
      <button type="submit" class="add-btn">เพิ่ม</button>
    </form>

    <p v-if="isLoading" class="loading">กำลังโหลด...</p>

    <ul v-else class="restaurant-list">
      <li v-for="item in menuList" :key="item.id" class="restaurant-item">
        <span class="item-name">{{ item.name }}</span>
      </li>
    </ul>
  </div>
</template>

What does this template do?

  • v-model="newName" creates two-way binding. When the user types, newName updates automatically. When handleSubmit clears it, the input empties.
  • @submit.prevent="handleSubmit" prevents the default page reload and runs your function instead.
  • v-if="isLoading" shows a loading message while the API call is in progress.
  • v-for="item in menuList" loops through the array and renders one <li> per restaurant. The :key="item.id" helps Vue track changes efficiently.
  • Save MenuListView.vue and switch to your browser at http://localhost:5173.
  • Click จัดการร้าน in the nav bar.

You should see the page title and any restaurants already in your database listed below the form. The page looks unstyled right now. You will add styles next.

Not seeing your restaurants?

  • Confirm your Elysia backend is still running on localhost:3000. If you restarted your computer, start it again.
  • Open the browser DevTools console and check for errors. A CORS error means the @elysia/cors plugin is not active in your backend.
  • Check that the import path in your script says '../composables/useMenu.js' with the correct relative path.

Help me debug why my MenuListView is not loading restaurants from the API.

Add styles to the management page

The page works but looks plain. Add styles to match the rest of the app.

  • Add this <style scoped> section below the closing </template> tag. Start with the page layout and form styles:
<style scoped>
.menu-page {
  padding-top: 16px;
}

.title {
  font-size: 24px;
  margin-bottom: 20px;
  color: #333;
}

.add-form {
  display: flex;
  gap: 8px;
  margin-bottom: 24px;
}

.input {
  flex: 1;
  padding: 12px 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
  font-size: 16px;
}

.input:focus {
  outline: none;
  border-color: #e65100;
}
  • Continue adding these styles directly below the .input:focus block, still inside the same <style scoped> tag:
.add-btn {
  background: #e65100;
  color: white;
  border: none;
  padding: 12px 20px;
  border-radius: 8px;
  font-size: 16px;
  cursor: pointer;
}

.add-btn:hover {
  background: #bf360c;
}

.loading {
  color: #888;
  text-align: center;
  padding: 32px 0;
}

.restaurant-list {
  list-style: none;
}

.restaurant-item {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: white;
  padding: 14px 16px;
  border-radius: 10px;
  margin-bottom: 8px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
  • Finally, add the last style rule and close the style tag:
.item-name {
  font-weight: 500;
}
</style>

What do these styles do?

The .add-form uses flexbox to lay the input and button side by side. The .input:focus rule gives the input an orange border when active, matching the app's accent color. The .restaurant-item cards use the same white-card style as the history page for visual consistency.

  • Save MenuListView.vue and refresh your browser.

Your management page now has a styled form at the top and a clean restaurant list below it.

Test the full add-restaurant flow

The composable pattern means your management page both reads and writes to the backend. Time to prove the whole cycle works.

  • In the input field on the /menu page, type a restaurant name (for example ข้าวมันไก่ลุงเฉย).
  • Click the เพิ่ม button.

Before you check the result, do you expect the new restaurant to appear immediately in the list below, or only after a page refresh?

The new restaurant appears in the list instantly. This happens because addNewMenu calls fetchMenuList() after the POST succeeds, which updates the reactive menuList ref. Vue automatically re-renders the v-for list when the ref changes.

  • Click สุ่มเมนู in the nav bar to return to the randomizer page.
  • Click สุ่มเมนูวันนี้ a few times.

The restaurant you just added should now appear in the random results. Your frontend and backend are working together across all three pages.

New restaurant not appearing in the list?

  • Check the DevTools console for a POST error. A 400 or 500 response means the backend is rejecting the request body.
  • Verify that handleSubmit trims the input. An empty string will be blocked by the if (!newName.value.trim()) return guard.
  • Make sure you are clicking the เพิ่ม button or pressing Enter. Just typing does not submit the form.

Help me debug why submitting a new restaurant is not working in my MenuListView.

💡 Why build the composable from the start?

In Step 3, you built RandomView with inline logic first, then extracted useRandom. That taught you what a composable does. Here you applied the pattern directly because you already understand it.

This is how production teams work. Once a pattern is established, you apply it from the start on new features. Both useRandom and useMenu follow the same shape: reactive refs, async functions, a plain return object. The pattern scales to any feature.

✔️ Awesome, I've got everything!

Great. Double check you have saved both src/composables/useMenu.js and src/views/MenuListView.vue.

ⓧ I'd like to double check the full code

Here is the complete useMenu.js:

import { ref } from 'vue'
import { getMenuList, addMenu } from '../api/menuApi.js'

export function useMenu() {
  const menuList = ref([])
  const isLoading = ref(false)

  async function fetchMenuList() {
    isLoading.value = true
    try {
      menuList.value = await getMenuList()
    } catch (error) {
      console.error('Failed to fetch menu list:', error)
    } finally {
      isLoading.value = false
    }
  }

  async function addNewMenu(name) {
    try {
      await addMenu({ name })
      await fetchMenuList()
    } catch (error) {
      console.error('Failed to add menu:', error)
    }
  }

  return { menuList, isLoading, fetchMenuList, addNewMenu }
}

Here is the complete MenuListView.vue script and template. Scroll up and confirm each section matches:

<script setup>
import { ref, onMounted } from 'vue'
import { useMenu } from '../composables/useMenu.js'

const { menuList, isLoading, fetchMenuList, addNewMenu } = useMenu()
const newName = ref('')

onMounted(() => {
  fetchMenuList()
})

async function handleSubmit() {
  if (!newName.value.trim()) return
  await addNewMenu(newName.value.trim())
  newName.value = ''
}
</script>

<template>
  <div class="menu-page">
    <h1 class="title">จัดการร้านอาหาร</h1>

    <form class="add-form" @submit.prevent="handleSubmit">
      <input
        v-model="newName"
        type="text"
        placeholder="ชื่อร้าน เช่น ส้มตำป้าแดง"
        class="input"
      />
      <button type="submit" class="add-btn">เพิ่ม</button>
    </form>
    <p v-if="isLoading" class="loading">กำลังโหลด...</p>

    <ul v-else class="restaurant-list">
      <li v-for="item in menuList" :key="item.id" class="restaurant-item">
        <span class="item-name">{{ item.name }}</span>
      </li>
    </ul>
  </div>
</template>

The full <style scoped> block should contain all the CSS classes shown in the styling substep above.

Your app now has a complete management page. Users can add restaurants and immediately see them in the randomizer. Next up, you will take on the Secret Mission and add category filtering to narrow down what kind of food gets randomized.

Secret mission

Add Category Filtering

Your randomizer picks from every restaurant in the list. What if you're craving Japanese food today? Add category filtering so you can narrow down results before spinning.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • The wannee-gin-arai frontend project folder on your Desktop.
  • The @elysia/cors package added to your Elysia backend.
  • The Vite dev server process running on port 5173.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to add more features soon.

  • Your Vite dev server continues running at http://localhost:5173.
  • Your Elysia backend continues running at http://localhost:3000.
  • The wannee-gin-arai folder stays on your Desktop.

Pause - I'll come back to this later

Shut down running processes to free up your terminal and memory. Your project files stay on disk so you can resume later.

  • In the terminal running the Vite dev server, press Ctrl+C to stop it.
  • In the terminal running your Elysia backend, press Ctrl+C to stop it.
  • To resume later, open both terminals again. Start the backend first, then run bun run dev inside the wannee-gin-arai folder.

Delete - I don't want to use this again

Remove all project resources completely. This cannot be undone.

  • Stop the Vite dev server by pressing Ctrl+C in its terminal.
  • Stop the Elysia backend by pressing Ctrl+C in its terminal.
  • Delete the frontend project folder by running:
cd ~/Desktop
rm -rf wannee-gin-arai

On Windows, run:

cd ~\Desktop
rmdir /s /q wannee-gin-arai
  • To remove the CORS plugin from your backend, open your backend project and run:
bun remove @elysia/cors
  • Then remove the import { cors } from '@elysia/cors' line and the .use(cors()) call from your backend entry file.

Nice Work!

Nice Work!

You just built a complete Vue 3 single-page application with a production-grade folder structure that talks to a real Elysia backend. Your food randomizer is fully functional in the browser.

You've learned how to:

  • Build a Vue 3 SPA with production folder structure using separated api/, composables/, views/, components/, and router/ directories.
  • Connect a frontend to a real backend with CORS and navigate between pages using Vue Router with lazy-loaded routes for code splitting.
  • Extract reusable logic into composables (useRandom, useMenu) using ref(), v-model, v-for, v-if, and onMounted.
  • Secret Mission: Added category-based filtering with a reusable CategoryFilter component, defineEmits, and composable integration.

Ready to quiz yourself?