はじめに
Webアプリを学び始めると,HTML,HTTP,サーバー,API,Ajax,DOM,SPAなど,たくさんの言葉が一度に登場します.また,ひと口にWebアプリといってもさまざまな構成があり,新人プログラマーがそれぞれの役割や関係を整理できず,混乱してしまうことも少なくありません.
この記事では,最初から本格的な開発環境を用意するのではなく,次の順番で小さなサンプルを発展させて理解を進めていきます.
- HTMLをブラウザで表示する
- CSSで見た目を整える
- リンクで別のHTMLへ移動する
- HTTPサーバーからHTMLを配信する
- Expressで静的ファイルを配信する
- ExpressでURLをルーティングする
- APIからJSONを取得して画面を書き換える
- VueとVue RouterでSPAにする
前提環境
この記事では,Node.jsとnpmがインストール済みであることを前提にします.
$ node --version
v20.19.2
$ npm --version
11.13.0
$ npx --version
11.13.0
1. まずはHTMLをブラウザで表示する
最初に,index.htmlを作ります.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello, Web</title>
</head>
<body>
<h1>Welcome!</h1>
<p>小さな一歩から,Webの世界を楽しもう.</p>
</body>
</html>
このファイルをブラウザで直接開くと,アドレス欄には次のようなURLが表示されます.
file:///.../index.html
この段階ではWebサーバーを使用していません.ブラウザがローカルのHTMLファイルを読み込み,HTMLを解釈して画面を描画しています.
2. CSSを用意して見た目を整える
HTMLの見た目を整えるため,style.cssを作ります.
CSSの詳しい説明はこの記事の本題ではないため,コードは折り畳んでいます.
style.css(クリックして展開)
* {
box-sizing: border-box;
}
body {
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
font-family: system-ui, sans-serif;
color: #243047;
}
.home-page {
background: linear-gradient(135deg, #dbeafe, #ede9fe 50%, #fce7f3);
}
.about-page {
background: linear-gradient(135deg, #dcfce7, #dbeafe 50%, #ede9fe);
}
.card {
width: min(100%, 560px);
padding: 48px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 24px;
box-shadow: 0 20px 50px rgba(76, 81, 130, 0.18);
backdrop-filter: blur(10px);
}
.home-page .card {
text-align: center;
}
.about-page .card {
width: min(100%, 640px);
}
.icon {
font-size: 56px;
}
h1 {
margin: 16px 0 8px;
font-size: clamp(36px, 8vw, 64px);
line-height: 1;
}
.about-page h1 {
margin: 0 0 16px;
font-size: clamp(32px, 7vw, 52px);
}
p {
margin: 0 0 28px;
color: #667085;
font-size: 18px;
line-height: 1.7;
}
.about-page p {
line-height: 1.8;
}
.api-demo {
margin: 28px 0 12px;
padding: 24px;
background: rgba(220, 252, 231, 0.65);
border-radius: 16px;
}
.api-demo h2 {
margin: 0 0 16px;
font-size: 22px;
}
.api-demo p {
min-height: 32px;
margin: 16px 0 0;
color: #166534;
font-weight: bold;
}
button {
padding: 12px 24px;
color: white;
font: inherit;
font-weight: bold;
background: #16a34a;
border: 0;
border-radius: 999px;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(22, 163, 74, 0.3);
}
button:disabled {
cursor: wait;
opacity: 0.6;
}
a {
display: inline-block;
padding: 12px 24px;
color: white;
font-weight: bold;
text-decoration: none;
background: #6366f1;
border-radius: 999px;
transition: transform 0.2s, box-shadow 0.2s;
}
.about-page a {
margin-top: 16px;
background: #16a34a;
}
a:hover {
transform: translateY(-2px);
box-shadow: 0 8px 18px rgba(99, 102, 241, 0.35);
}
.about-page a:hover {
box-shadow: 0 8px 18px rgba(22, 163, 74, 0.3);
}
HTMLのheadからCSSを読み込みます.
<link rel="stylesheet" href="style.css">
index.htmlの本文をカードとして囲み,絵文字とリンクも追加します.絵文字は見栄えを少し調整するためのもので,特別な意味はありません.リンク先のabout.htmlはまだ作成していないため,この時点ではリンクは正常に動作しません.次の章の準備として,先に用意しておきます.
<body class="home-page">
<main class="card">
<div class="icon">👋</div>
<h1>Welcome!</h1>
<p>小さな一歩から,Webの世界を楽しもう.</p>
<a href="about.html">はじめてみる</a>
</main>
</body>
HTMLは文書の構造,CSSは見た目を担当します.スタイルはHTMLの中に記述することもできますが,ファイルを分けることで,それぞれの役割が分かりやすくなります.
3. リンクで別のHTMLへ移動する
次に,リンク先となるabout.htmlを作ります.同じCSSを読み込みつつ,about-pageクラスによって背景やボタンの色を変えます.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>About Web</title>
<link rel="stylesheet" href="style.css">
</head>
<body class="about-page">
<main class="card">
<h1>Webの世界へ! 🚀</h1>
<p>
別のHTMLファイルへ移動できました.<br>
Webページ同士は,リンクを使って簡単につなげられます.
</p>
<a href="index.html">← 最初のページへ戻る</a>
</main>
</body>
</html>
これで,前章のindex.htmlに用意したリンクからabout.htmlへ移動できるようになりました.また,about.htmlに追加したリンクからindex.htmlへ戻ることもできます.
href="about.html"やhref="index.html"のような指定は,相対URLと呼ばれます.相対URLでは,現在表示しているページのURLを基準にリンク先が決まります.
file:///.../index.html + about.html
→ file:///.../about.html
http://localhost:3000/index.html + about.html
→ http://localhost:3000/about.html
同じHTMLでも,最初のページをfile:で開いたかhttp:で開いたかによって,リンク先のURLも変わります.ここまではfile:でローカルファイルを直接開いていますが,次章ではHTTPサーバーを起動し,http:でアクセスしてみます.
4. HTTPサーバーからHTMLを配信する
ここまではHTMLファイルを直接開いていました.次はHTTPサーバーを起動します.
HTMLがあるディレクトリで,次のコマンドを実行します.serveは,HTMLやCSSなどの静的ファイルをHTTPで配信するためのツールです.
$ npx serve
起動するとターミナルにURLが表示されるので,ブラウザで開きます.通常は次のURLです.
http://localhost:3000/
今度はアドレス欄がfile:ではなくhttp:から始まります.ブラウザはローカルファイルを直接読むのではなく,HTTPサーバーへリクエストを送り,返されたHTMLを表示しています.サーバーを停止する場合は,ターミナルでCtrl+Cを押します.
この状態でリンクをクリックすると,ブラウザはhttp://localhost:3000/about.htmlへ新しいリクエストを送ります.HTTPサーバーはそのリクエストに対応するabout.htmlを返し,ブラウザが画面を表示します.
なぜindex.htmlが自動的に表示されるのか
これはHTTP自体の仕様ではなく,Webサーバーで広く使われている慣習です.
Webサーバーは一般的に,ディレクトリへのリクエストを受けると次のように処理します.
GET /
↓
既定ファイル(index.htmlなど)を探す
↓
あれば返す
↓
なければ,設定に応じてファイル一覧やエラーを返す
本番環境では,意図しないファイル名や構成を公開しないよう,ディレクトリ一覧を無効にすることが一般的です.
5. Expressで静的ファイルを配信する
ここまでは既成のツールを使ってHTTPサーバーを起動しました.次はExpressを使い,同じように静的ファイルを配信するサーバーを自分で書いてみます.
まず,次のコマンドでNode.jsプロジェクトを初期化します.-yを付けると,すべての質問にデフォルト値で回答し,package.jsonが自動生成されます.
$ npm init -y
次に,Expressをインストールします.
$ npm install express
このコマンドにより,Express本体がインストールされると同時に,package.jsonのdependenciesへExpressが自動的に追加されます.
生成されたpackage.jsonを,今回使用する項目だけに整理します.server.jsでimport構文を使うため,typeをmoduleへ変更します.また,サーバーを起動するためのstartスクリプトと,誤ってnpmへ公開されることを防ぐprivateを追加します.最終的な内容は次のようになります.なお,Expressのバージョンはインストールした時期によって異なります.
{
"name": "hello-web",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^5.2.1"
}
}
最小限のserver.jsは次のようになります.
import express from "express";
const app = express();
const port = 3000;
app.use(express.static("."));
app.listen(port, () => {
console.log(`http://localhost:${port}`);
});
ここでは動きを確認するためプロジェクト全体を配信対象にしていますが,次章で公開するファイルをpublicディレクトリへ分離します.
起動します.
$ npm start
express.static(".")は,現在のディレクトリにあるHTMLやCSSを静的ファイルとして配信します.
GET / → index.html
GET /about.html → about.html
GET /style.css → style.css
6. URLと物理ファイルをルーティングで分離する
WebアプリのURLは,必ずしも物理ファイル名を表すものではありません.
/about → about.htmlの内容を返す
Expressで対応関係を決められます.
画面として扱うHTMLはルーティングを記述し,CSSなどの静的ファイルはpublicディレクトリへまとめます.style.cssを移動し,次の構成にします.
webapp/
├── public/
│ └── style.css
├── index.html
├── about.html
├── server.js
└── package.json
import express from "express";
import path from "node:path";
import { fileURLToPath } from "node:url";
const app = express();
const port = 3000;
const directory = path.dirname(fileURLToPath(import.meta.url));
app.use(express.static(path.join(directory, "public")));
app.get("/", (request, response) => {
response.sendFile(path.join(directory, "index.html"));
});
app.get("/about", (request, response) => {
response.sendFile(path.join(directory, "about.html"));
});
app.listen(port, () => {
console.log(`http://localhost:${port}`);
});
app.get()は,第1引数でURL,第2引数でそのURLへリクエストが来たときの処理を指定します.response.sendFile()には絶対パスを渡す必要があるため,import.meta.urlからserver.jsがあるディレクトリのパスを取得し,返したいファイル名と結合しています.
一方,express.static()は指定したディレクトリにある静的ファイルを配信します.ブラウザから/style.cssへリクエストが来ると,Expressはpublic/style.cssを返します.HTMLに記述したhref="style.css"は,そのままで問題ありません.
リンクもファイル名ではなく,アプリのURLへ向けます.
<a href="/about">はじめてみる</a>
<a href="/">← 最初のページへ戻る</a>
サーバーを一度停止し,npm startで再起動してからブラウザで確認します.
この時点では,リンクをクリックするたびにブラウザがサーバーへ新しいHTTPリクエストを送り,HTML全体を取得します.
テンプレートエンジンについて
今回は完成済みのHTMLファイルをsendFile()で返していますが,サーバー側のデータを埋め込み,リクエストごとにHTMLを生成する方法もあります.そのために使われるのが,EJS,Pug,Handlebarsなどのテンプレートエンジンです.現在もサーバー側でHTMLを生成するWebアプリなどで使われていますが,この記事では詳しい説明や実装を省略します.
7. APIからJSONを取得して画面を書き換える
次は,HTMLではなくデータを返すURLを追加します.server.jsのapp.listen()より前に,次のルートを追加します.
app.get("/api/message", (request, response) => {
response.json({
message: "Expressからメッセージが届きました!",
receivedAt: new Date().toLocaleString("ja-JP"),
});
});
response.json()は,JavaScriptのオブジェクトをJSON形式で返します.ブラウザでhttp://localhost:3000/api/messageへアクセスすると,次のようなJSONを確認できます.
{
"message": "Expressからメッセージが届きました!",
"receivedAt": "2026/8/22 16:30:00"
}
server.jsを変更したため,サーバーを再起動します.
次はブラウザからAPIを呼び出して,画面を更新します.
About画面のカード内に,APIデモ用のボタンと表示領域を追加します.
<section class="api-demo">
<h2>サーバーと通信してみよう</h2>
<button id="load-message" type="button">メッセージを取得</button>
<p id="message">ボタンを押すと,ここに結果が表示されます.</p>
</section>
<script src="/script.js"></script>
scriptタグは,bodyの閉じタグの直前に配置します.
publicディレクトリにscript.jsを作り,ブラウザからAPIを呼び出します.
const button = document.querySelector("#load-message");
const message = document.querySelector("#message");
button.addEventListener("click", async () => {
button.disabled = true;
message.textContent = "サーバーへ問い合わせています…";
try {
const response = await fetch("/api/message");
if (!response.ok) {
throw new Error(`HTTPエラー: ${response.status}`);
}
const data = await response.json();
message.textContent = `${data.message}(${data.receivedAt})`;
} catch (error) {
message.textContent = `通信に失敗しました: ${error.message}`;
} finally {
button.disabled = false;
}
});
処理の流れは次のとおりです.
ボタンをクリック
↓
fetch()でGET /api/message
↓
ExpressがJSONを返す
↓
response.json()で読み取る
↓
textContentで画面の一部を更新
ページ全体を再読み込みせず,JavaScriptでサーバーと非同期通信し,画面の一部を更新しています.これは現代的なAjaxの例です.
8. VueとVue RouterでSPAにする
最後に,2つの画面をVueのSPA(Single Page Application)として作り直します.
従来型の複数ページ構成では,リンクを開くたびにサーバーから新しいHTMLを受け取り,ページ全体を表示し直します.一方,SPAでは最初に読み込んだHTMLの上でJavaScriptが表示内容を切り替えるため,ページ遷移を滑らかにしたり,画面の状態を保ちやすくしたりできます.
ただし,SPAはJavaScriptの量や構成が複雑になりやすく,最初の読み込みやURLへの直接アクセスにも配慮が必要です.どちらかが常に優れているわけではなく,画面操作の多いアプリではSPA,内容をページ単位で見せるサイトでは従来型の構成,というように用途に応じて選びます.
今回は仕組みを見やすくするため,.vueファイルやViteを使わず,CDNからVueを読み込みます.
HTMLを1枚にする
about.htmlは削除し,index.htmlをVueアプリの土台にします.
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello, Web</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div id="app"></div>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script src="https://unpkg.com/vue-router@4/dist/vue-router.global.js"></script>
<script src="/script.js"></script>
</body>
</html>
これまではbody自身が画面全体のレイアウトを担当していましたが,SPAでは画面コンポーネント側へその役割を移します.style.cssのbodyを次のように変更し,.pageを追加します.そのほかのカードや背景のスタイルは,ここまでに作ったものをそのまま利用できます.
body {
margin: 0;
font-family: system-ui, sans-serif;
color: #243047;
}
.page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
Vueの画面を定義する
public/script.jsを置き換え,Home画面とAbout画面を定義します.
const { createApp, ref } = Vue;
const { createRouter, createWebHistory } = VueRouter;
const Home = {
template: `
<div class="page home-page">
<main class="card">
<div class="icon">👋</div>
<h1>Welcome!</h1>
<p>小さな一歩から,Webの世界を楽しもう.</p>
<router-link to="/about">はじめてみる</router-link>
</main>
</div>
`,
};
const About = {
setup() {
const message = ref("ボタンを押すと,ここに結果が表示されます.");
const loading = ref(false);
async function loadMessage() {
loading.value = true;
message.value = "サーバーへ問い合わせています…";
try {
const response = await fetch("/api/message");
if (!response.ok) {
throw new Error(`HTTPエラー: ${response.status}`);
}
const data = await response.json();
message.value = `${data.message}(${data.receivedAt})`;
} catch (error) {
message.value = `通信に失敗しました: ${error.message}`;
} finally {
loading.value = false;
}
}
return { message, loading, loadMessage };
},
template: `
<div class="page about-page">
<main class="card">
<h1>Webの世界へ! 🚀</h1>
<p>
Vue Routerで画面を切り替えました.<br>
ページ全体は再読み込みされていません.
</p>
<section class="api-demo">
<h2>サーバーと通信してみよう</h2>
<button type="button" :disabled="loading" @click="loadMessage">
{{ loading ? "通信中…" : "メッセージを取得" }}
</button>
<p>{{ message }}</p>
</section>
<router-link to="/">← 最初の画面へ戻る</router-link>
</main>
</div>
`,
};
Vueでは,ref()で作った状態を変更すると,それを参照している画面が自動的に更新されます.
message.value = data.message;
先ほどのようにdocument.querySelector()で実DOMを取得し,textContentを書き換える必要はありません.
Vue Routerを設定する
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: Home },
{ path: "/about", component: About },
],
});
createApp({
template: "<router-view></router-view>",
})
.use(router)
.mount("#app");
<router-link>をクリックすると,Vue RouterがブラウザのHistory APIを使ってURLを変更し,表示するコンポーネントを切り替えます.
クリック
↓
URLを /about に変更
↓
Aboutコンポーネントを表示
↓
必要な実DOMを更新
今回の画面遷移では,WebサーバーへHTMLを取得しに行きません.APIを呼ぶ操作は別なので,そのときはサーバー通信が発生します.
ExpressでSPAのURLに対応する
今回のSPA内の画面遷移では,HTMLを取得するためのサーバー通信は発生しませんが,/aboutを直接開いた場合や,その状態で再読み込みした場合は,ブラウザからExpressへGET /aboutが送られます.
そのため,画面用のURLには同じindex.htmlを返します.
// APIや静的ファイルのルートを先に書く
app.use(express.static(path.join(directory, "public")));
app.get("/api/message", (request, response) => {
response.json({
message: "Expressからメッセージが届きました!",
receivedAt: new Date().toLocaleString("ja-JP"),
});
});
// 最後にSPAのフォールバックを書く
app.get("/{*splat}", (request, response) => {
response.sendFile(path.join(directory, "index.html"));
});
Express 5では,/{*splat}がルートの/を含む任意のパスにマッチします.
ルートは上から順に評価されるため,ワイルドカードは最後に書きます.先に書くと,/api/messageへアクセスしてもJSONではなくindex.htmlが返ってしまいます.
server.jsを変更したため,サーバーを再起動します.
処理全体は次のようになります.
最初に /about を直接開く
↓
Expressがindex.htmlを返す
↓
Vueアプリが起動する
↓
Vue Routerが現在のURLを確認する
↓
Aboutコンポーネントを表示する
ここまででうまくいくと,これまでと同じ画面が表示され,SPAとして画面遷移できるようになります.
まとめ
今回は,何もないHTMLから始めて,少しずつWebアプリらしい構成へ発展させてみました.
- HTMLとCSSで画面を作る
- リンクで別のページへ移動する
- HTTPサーバーからファイルを配信する
- ExpressでURLをルーティングする
- APIからデータを受け取り,JavaScriptで画面を書き換える
- VueとVue RouterでSPAにする
最初からVueを使うだけでも同じような画面は作れますが,順番に試してみると,ブラウザ,Webサーバー,JavaScript,フレームワークがそれぞれ何を担当しているのか見えやすくなります.
この記事ではブラウザ側で画面を作るSPAを扱いましたが,サーバー側でHTMLを生成して返すSSR(Server-Side Rendering)という構成もあります.Next.jsやNuxtなどでは,最初の画面をSSRで表示し,その後はSPAのように滑らかに操作する,両方を組み合わせた構成もよく使われています.
今回はAPIから固定のメッセージを返したためデータベースを使いませんでしたが,実際のWebアプリでは,ユーザーや投稿などのデータをサーバーを通してデータベースに保存し,必要に応じて読み出す構成がよく使われます.
ここから先は,Viteと.vueファイルを使った開発環境へ移ったり,フォームからAPIへデータを送って実際のデータを扱ったりすると,さらにWebアプリらしくなっていきます.まずはコードを少し変えたり,画面をひとつ増やしたりしながら,気軽に遊んでみてください!




