Khi mới bắt đầu xây dựng ứng dụng web với Express + EJS, hầu hết mọi người đều bắt đầu với Partials — tách header, footer, sidebar ra thành các file riêng rồi include vào từng trang.
Trông có vẻ ổn, cho đến khi bạn có 10 trang, và mỗi trang đều bắt đầu như thế này:
<!-- views/pages/letters.ejs -->
<%- include('../partials/header') %>
<%- include('../partials/topbar') %>
<%- include('../partials/sidebar') %>
<main>
<!-- nội dung trang -->
</main>
<%- include('../partials/footer') %>
Vấn đề chưa dừng ở đó. Khi cần thêm một thẻ <script> riêng cho một trang, bạn phải nhét nó vào footer partial — và script đó sẽ load ở mọi trang, dù chỉ cần ở một trang duy nhất.
Layouts giải quyết cả hai vấn đề này.
Layout là gì?
Thay vì mỗi view tự include các partial, layout là khung HTML bao ngoài — nó đã chứa sẵn header, footer, sidebar. View của bạn chỉ cần cung cấp phần nội dung thay đổi, layout sẽ tự điền vào đúng chỗ.
Hình dung đơn giản:
Partials approach: Layout approach:
───────────────── ──────────────────
view tự include layout bao ngoài view
→ header layout chứa: header
→ topbar topbar
→ sidebar sidebar
→ [nội dung] view chỉ chứa: [nội dung]
→ footer footer
Package phổ biến nhất để dùng Layouts với Express + EJS là express-ejs-layouts.
Cài đặt & Cấu hình
npm install express-ejs-layouts
// app.js
const express = require('express');
const expressLayouts = require('express-ejs-layouts');
const app = express();
app.set('view engine', 'ejs');
app.set('views', './views');
app.use(expressLayouts);
app.set('layout', 'layouts/main'); // layout mặc định cho toàn app
Chỉ vậy thôi. Từ giờ, mọi res.render() sẽ tự động dùng layouts/main.ejs làm khung.
Cấu trúc thư mục
views/
├── layouts/
│ ├── main.ejs ← 2-column (sidebar + content)
│ └── auth.ejs ← centered box (login, signup...)
├── partials/
│ ├── topbar.ejs
│ ├── sidebar.ejs
│ └── footer.ejs
└── pages/
├── home.ejs
├── letters.ejs
└── signin.ejs
Viết Layout đầu tiên
Layout chính: 2-column (layouts/main.ejs)
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title><%= typeof title !== 'undefined' ? title : 'My App' %></title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body>
<%- include('../partials/topbar') %>
<div class="layout-2col">
<aside class="sidebar">
<%- include('../partials/sidebar') %>
</aside>
<main class="content">
<%- body %>
</main>
</div>
<%- include('../partials/footer') %>
</body>
</html>
<%- body %> là từ khóa quan trọng nhất — đây là chỗ express-ejs-layouts sẽ đặt nội dung từ view của bạn vào. Không có dòng này, layout sẽ không hiển thị gì cả.
Layout phụ: Centered box (layouts/auth.ejs)
Dùng cho các trang đăng nhập, đăng ký, xác nhận email — không cần topbar hay sidebar.
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<title><%= typeof title !== 'undefined' ? title : 'My App' %></title>
<link rel="stylesheet" href="/css/app.css">
</head>
<body class="auth-page">
<div class="auth-wrapper">
<div class="auth-box">
<%- body %>
</div>
</div>
</body>
</html>
Sử dụng trong Controller
// routes/letters.js — dùng layout mặc định, không cần khai báo
router.get('/', (req, res) => {
res.render('pages/letters', {
title: 'Thư của tôi'
});
});
// routes/auth.js — override sang layout khác
router.get('/signin', (req, res) => {
res.render('pages/signin', {
title: 'Đăng nhập',
layout: 'layouts/auth',
});
});
// Tắt hoàn toàn layout — dùng khi trả về HTML fragment (HTMX...)
router.get('/letters/fragment', (req, res) => {
res.render('partials/letter-card', {
layout: false
});
});
View (letters.ejs, signin.ejs) chỉ cần viết phần nội dung, không cần include gì thêm:
<!-- views/pages/letters.ejs -->
<div class="letters-list">
<h1>Thư của tôi</h1>
<!-- ... -->
</div>
Content Blocks: Slot tùy chỉnh
Đây là tính năng giúp giải quyết bài toán script riêng từng trang.
Ý tưởng: layout “đặt chỗ sẵn” bằng defineContent, view nào cần thì tự “điền vào” bằng contentFor. View nào không điền → chỗ đó trống, không lỗi.
Khai báo slot trong layout
<!-- layouts/main.ejs -->
<head>
<link rel="stylesheet" href="/css/app.css">
<%- defineContent('extraHead') %> ← slot cho <meta>, <link> riêng
</head>
<body>
<%- body %>
<script src="/js/htmx.min.js"></script> ← script chung, luôn có
<%- defineContent('extraScripts') %> ← slot cho script riêng từng trang
</body>
View điền vào slot
<!-- views/pages/compose.ejs -->
<% contentFor('extraHead') %>
<meta name="robots" content="noindex">
<% end %>