Overview
Every Vue app so far in this course has been a single view. A blog is the natural project for introducing multiple pages, because it has an obvious two-level structure: a list page showing every post, and a detail page showing one post in full — and clicking a post has to change the URL, not just swap out some local state, so the browser's back button, refresh, and shareable links all keep working correctly.
This tutorial uses `vue-router`, the official routing library for Vue, to handle that. `createRouter` with `createWebHistory` builds the router, `<RouterView>` renders whichever page component matches the current URL, `<RouterLink>` changes the URL without a full page reload, `useRoute` reads dynamic segments like a post's id out of the URL, and `useRouter` triggers a route change from code (for a "Back" button, for example).
- A `PostList` page that renders every post as a preview card linking to its own URL.
- A `PostDetail` page reached at `/posts/:postId`, showing one post's full content.
- Real client-side navigation with `<RouterLink>` — no full-page reloads between the list and a post.
- `useRoute` to read which post to display straight out of the current URL's dynamic `:postId` segment.
- A "Back to all posts" button built with `useRouter` for imperative navigation.
- A route configuration in `main.js` mapping each URL pattern to its page component.
Prerequisites
- Template syntax, Single File Components, and props — covered in the Todo App and Shopping Cart projects above.
- Array methods — `find` and `map`, used to look up and list posts.
- Basic familiarity with URLs — paths, and dynamic segments like `:postId`.
- Vue Router itself is new here — every function and component it provides is introduced from scratch in this tutorial.
Project Structure
Post data lives in its own file as a plain array, since — like the product catalog in the Shopping Cart project — it does not change while the app runs. `PostList` and `PostDetail` are two separate page components, each rendered by `<RouterView>` when its route matches, and each receiving the data it needs by looking it up from the shared `POSTS` array rather than through props passed down a component tree.
src/ posts.js // Static array of blog posts router/ index.js // createRouter + route table mapping URLs to the two page components pages/ PostList.vue // "/" — every post as a preview card, each linking to its detail page PostDetail.vue // "/posts/:postId" — one post's full content, looked up by the route param App.vue // Just a <RouterView> — the router decides what renders inside it main.js // Creates the app, installs the router, mounts it style.css // Shared dark-theme styling* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: Arial, Helvetica, sans-serif; background: #1a1d23; color: #e8e8e8; display: flex; justify-content: center; padding: 40px 16px;}
.blog-app { width: 100%; max-width: 560px; }.blog-app h1 { margin-bottom: 20px; font-size: 1.6rem; }
.post-preview { background: #23262e; border-radius: 8px; padding: 16px 18px; margin-bottom: 14px;}
.post-preview h2 { font-size: 1.1rem; margin-bottom: 6px; }.post-preview a { color: #e8e8e8; text-decoration: none; }.post-preview a:hover { color: #42b883; }.post-author { color: #9aa4b2; font-size: 0.85rem; margin-top: 6px; }
.post-detail { background: #23262e; border-radius: 8px; padding: 20px; }.post-detail h1 { font-size: 1.4rem; margin-bottom: 8px; }.post-detail p { line-height: 1.6; margin-top: 10px; }
.back-btn { background: transparent; border: 1px solid #3a3f4b; color: #9aa4b2; border-radius: 6px; padding: 6px 12px; cursor: pointer; margin-bottom: 16px;}Step 1: Install and Set Up Vue Router
`vue-router` is a separate package from Vue itself, installed the normal way with npm. `createWebHistory()` tells the router to use the browser's real URL bar (as opposed to a `#`-based hash route) — it is what listens to the address bar and makes `<RouterLink>`, `useRoute`, and `useRouter` work anywhere in the app.
npm install vue-router// router/index.jsimport { createRouter, createWebHistory } from 'vue-router';
export const router = createRouter({ history: createWebHistory(), // listens to the browser's real URL bar routes: [ // individual route entries are added in Step 6, once PostList and // PostDetail exist to point them at ],});Step 2: Define the Posts Data and Route Plan
Each post gets a URL-friendly `id` string (not just a numeric index) so links stay meaningful and stable even if posts are reordered or added later. Sketching the two routes out in a comment before writing any component code is worth doing deliberately — it is what decides what each page component needs to read from the URL.
// posts.jsexport const POSTS = [ { id: 'vue-reactivity', title: 'Understanding Vue Reactivity', excerpt: 'A practical tour of ref and computed, and how Vue tracks changes under the hood.', body: 'Vue wraps state in Proxy objects so that reading a property during render registers it as a dependency, and writing to it later triggers exactly the components that read it to re-render. ref() does this for a single value; reactive() does it for a whole object at once.', author: 'Dana Lee', }, { id: 'css-grid', title: 'CSS Grid in 10 Minutes', excerpt: 'The handful of Grid properties that cover almost every real layout.', body: 'CSS Grid turns layout into a two-dimensional problem you describe once on a container, instead of a chain of floats or flex wrappers. grid-template-columns and gap alone solve most everyday page layouts.', author: 'Sam Ortiz', }, { id: 'js-async', title: 'Async JavaScript Explained', excerpt: 'How Promises, async/await, and the event loop actually fit together.', body: 'JavaScript runs on a single thread, so async/await is really just readable syntax sitting on top of Promises and the event loop — the mental model of "callbacks queued for later" never goes away, async/await just makes it look sequential.', author: 'Priya Nair', },];
/*Route plan: "/" -> PostList (every post's title + excerpt, each linking out) "/posts/:postId" -> PostDetail (one post's full body, looked up by the :postId URL segment)*/Step 3: Build the Post List Page
`<RouterLink to="...">` renders an `<a>` under the hood, but intercepts the click to swap the page's content through Vue Router instead of asking the browser to fully reload — that is what keeps the app feeling instant while still producing a real, bookmarkable URL for every post.
<!-- pages/PostList.vue --><script setup>import { POSTS } from '../posts';</script>
<template> <div class="post-list"> <h1>Blog</h1> <article class="post-preview" v-for="post in POSTS" :key="post.id"> <h2> <!-- RouterLink swaps the rendered page without a full browser reload — a plain <a href="..."> would re-download the whole app --> <RouterLink :to="'/posts/' + post.id">{{ post.title }}</RouterLink> </h2> <p>{{ post.excerpt }}</p> <p class="post-author">By {{ post.author }}</p> </article> </div></template>Step 4: Build the Post Detail Page with a Dynamic Route
`useRoute()` returns a reactive object describing the current route. For a route declared as `/posts/:postId`, visiting `/posts/css-grid` makes `route.params.postId` equal `'css-grid'` — that value is then used to look the matching post up out of the same shared `POSTS` array `PostList` already uses. Wrapping the lookup in `computed` means it re-runs automatically if the route ever changes while this component is already mounted (navigating from one post directly to another, for example).
<!-- pages/PostDetail.vue --><script setup>import { computed } from 'vue';import { useRoute } from 'vue-router';import { POSTS } from '../posts';
const route = useRoute(); // pulls the current route (including its params) reactivelyconst post = computed(() => POSTS.find((p) => p.id === route.params.postId));</script>
<template> <!-- Handles a mistyped or stale URL gracefully instead of crashing on post.title --> <p v-if="!post">Post not found.</p> <article v-else class="post-detail"> <h1>{{ post.title }}</h1> <p class="post-author">By {{ post.author }}</p> <p>{{ post.body }}</p> </article></template>Step 5: Navigate Back with the Router Instance
`<RouterLink>` is the right tool when the user clicks something that looks and behaves like a link. `useRouter()` (note: no "params" — this is the router instance itself, not the current route) is for triggering a route change from inside a function — here, a plain `<button>` that calls `router.push('/')` when clicked, which is a more natural fit than styling a `<RouterLink>` to look like a button.
<!-- pages/PostDetail.vue (updated) --><script setup>import { computed } from 'vue';import { useRoute, useRouter } from 'vue-router';import { POSTS } from '../posts';
const route = useRoute();const router = useRouter(); // imperative navigation — call it from code, not by rendering a RouterLinkconst post = computed(() => POSTS.find((p) => p.id === route.params.postId));</script>
<template> <p v-if="!post">Post not found.</p> <article v-else class="post-detail"> <button type="button" class="back-btn" @click="router.push('/')"> ← Back to all posts </button> <h1>{{ post.title }}</h1> <p class="post-author">By {{ post.author }}</p> <p>{{ post.body }}</p> </article></template>Step 6: Wire Up the Routes and RouterView
Each route entry pairs a URL pattern with the component to render for it. `<RouterView>` is a placeholder — Vue Router matches the current browser URL against every `path` in the table and renders the matching `component` right there. `:postId` in the second route is a placeholder that matches any value in that URL segment, which is exactly what `route.params.postId` reads back out inside `PostDetail`.
// router/index.js (final)import { createRouter, createWebHistory } from 'vue-router';import PostList from '../pages/PostList.vue';import PostDetail from '../pages/PostDetail.vue';
export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: PostList }, { path: '/posts/:postId', component: PostDetail }, ],});<!-- App.vue --><template> <div class="blog-app"> <!-- RouterView renders whichever page component matches the current URL --> <RouterView /> </div></template>// main.jsimport { createApp } from 'vue';import App from './App.vue';import { router } from './router';
createApp(App).use(router).mount('#app'); // registers the router so RouterView/RouterLink/useRoute/useRouter all workComplete Code
Here is the complete project — every file, fully assembled from the steps above, using real `vue-router` exactly as you would install and use it in a bundled app.
// posts.jsexport const POSTS = [ { id: 'vue-reactivity', title: 'Understanding Vue Reactivity', excerpt: 'A practical tour of ref and computed, and how Vue tracks changes under the hood.', body: 'Vue wraps state in Proxy objects so reading a property during render registers it as a dependency, and writing to it later triggers exactly the components that read it to re-render.', author: 'Dana Lee', }, { id: 'css-grid', title: 'CSS Grid in 10 Minutes', excerpt: 'The handful of Grid properties that cover almost every real layout.', body: 'CSS Grid turns layout into a two-dimensional problem you describe once on a container.', author: 'Sam Ortiz', }, { id: 'js-async', title: 'Async JavaScript Explained', excerpt: 'How Promises, async/await, and the event loop actually fit together.', body: 'JavaScript runs on a single thread, so async/await is readable syntax sitting on top of Promises.', author: 'Priya Nair', },];// router/index.jsimport { createRouter, createWebHistory } from 'vue-router';import PostList from '../pages/PostList.vue';import PostDetail from '../pages/PostDetail.vue';
export const router = createRouter({ history: createWebHistory(), routes: [ { path: '/', component: PostList }, { path: '/posts/:postId', component: PostDetail }, ],});<!-- pages/PostList.vue --><script setup>import { POSTS } from '../posts';</script>
<template> <div class="post-list"> <h1>Blog</h1> <article class="post-preview" v-for="post in POSTS" :key="post.id"> <h2><RouterLink :to="'/posts/' + post.id">{{ post.title }}</RouterLink></h2> <p>{{ post.excerpt }}</p> <p class="post-author">By {{ post.author }}</p> </article> </div></template><!-- pages/PostDetail.vue --><script setup>import { computed } from 'vue';import { useRoute, useRouter } from 'vue-router';import { POSTS } from '../posts';
const route = useRoute();const router = useRouter();const post = computed(() => POSTS.find((p) => p.id === route.params.postId));</script>
<template> <p v-if="!post">Post not found.</p> <article v-else class="post-detail"> <button type="button" class="back-btn" @click="router.push('/')">← Back to all posts</button> <h1>{{ post.title }}</h1> <p class="post-author">By {{ post.author }}</p> <p>{{ post.body }}</p> </article></template><!-- App.vue --><template> <div class="blog-app"> <RouterView /> </div></template>// main.jsimport { createApp } from 'vue';import App from './App.vue';import { router } from './router';
createApp(App).use(router).mount('#app');`vue-router`'s `createWebHistory()` needs the browser's real History API, which is awkward to get working inside a sandboxed preview iframe. The live preview below reimplements just the "current page" switching with a `ref` holding the current view and post id, driven by the exact same `POSTS` data and the exact same page components' logic — everything about how `PostList` and `PostDetail` actually render is unchanged; only the mechanism that decides which one is showing is simplified, standing in for what `<RouterView>`/`<RouterLink>`/`useRouter` would do in the real, bundled app above.
Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a catch-all route (`path: '/:pathMatch(.*)*', component: NotFound`) for any URL that does not match the other routes.
- Add a category filter using a query string (e.g. `/?category=vue`) read with `route.query`.
- Wrap `PostList` and `PostDetail` in a shared layout (header/footer) that stays outside `<RouterView>` in `App.vue`.
- Add a simple "New Post" form at `/new` that calls `router.push('/posts/' + newPost.id)` after submitting.
- Highlight the currently active link in a nav bar using `<RouterLink>`'s automatic `router-link-active` class.
Summary
You built a multi-page Vue app using `vue-router` to map URLs to page components, `<RouterLink>` for real, no-reload navigation, `useRoute` to read a post's id out of the current URL's dynamic segment, and `useRouter` for navigation triggered from code. This routing pattern — a list page linking into detail pages identified by a URL param — is the backbone of nearly every content-driven Vue app you'll build beyond this course, from blogs to e-commerce product pages to documentation sites.