LearnAI ToolsCareerPractice BuildsPlayContact
Vue.jsIntermediate~2.5 hours

Multi-Page Blog

Build a blog with routing between a post list and detail pages.

Vue RouterDynamic RoutesComponents

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).

What You'll Build
  • 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.js
import { 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.js
export 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) reactively
const 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 RouterLink
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('/')">
&larr; 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.js
import { 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 work

Complete 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.js
export 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.js
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 },
],
});
<!-- 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('/')">&larr; 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.js
import { 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.

Live Preview

Sample Run

Sample Interaction

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.