Skip to content

EJS Layouts: Tổ chức giao diện gọn gàng hơn trong Express.js

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 %>

<% contentFor('extraScripts') %>
<script src="/js/editor.js"></script>
<script>Editor.init({ maxChars: 5000 });</script>
<% end %>

<div class="compose-form">
<h1>Viết thư mới</h1>
<!-- ... -->
</div>
HTML trình duyệt nhận được sẽ là:
<head>
<link rel="stylesheet" href="/css/app.css">
<meta name="robots" content="noindex"> ← chỉ xuất hiện ở trang này
</head>
<body>
<div class="compose-form">...</div>
<script src="/js/htmx.min.js"></script>
<script src="/js/editor.js"></script> ← chỉ xuất hiện ở trang này
</body>
Còn trang letters.ejs không khai báo contentFor nào → hai slot đó trống hoàn toàn, sạch sẽ.

Lưu ý thực tế

Dùng layout: false với HTMX
Khi HTMX fetch một fragment HTML, bạn chỉ muốn trả về mảnh đó, không phải cả trang. Có thể detect tự động qua header:
router.get('/letters', (req, res) => {
const isHtmx = req.headers['hx-request'];
res.render('pages/letters', {
layout: isHtmx ? false : 'layouts/main',
letters: data,
});
});
Truyền data vào Partial qua res.locals
Nếu sidebar cần data động (ví dụ: số thông báo chưa đọc), đừng để mỗi route phải tự truyền. Dùng middleware:
// middleware/locals.js
app.use(async (req, res, next) => {
if (req.user) {
res.locals.unreadCount = await Notification.countUnread(req.user.id);
}
next();
});
sidebar.ejs dùng thẳng <%= unreadCount %> mà không cần route nào nhớ truyền.
Layout không lồng được vào nhau
express-ejs-layouts không hỗ trợ layout kế thừa layout. Nếu hai layout có phần giống nhau, hãy tách phần đó thành partial dùng chung — đừng cố lồng layout.
Đặt layout mặc định cho layout phổ biến nhất
app.set('layout', 'layouts/main'); // layout dùng cho 80% trang
Chỉ những trang đặc biệt mới cần override. Hoặc gom logic đó vào middleware để tránh lặp lại trong từng route:
// Áp dụng auth layout cho tất cả route trong /auth
router.use((req, res, next) => {
res.locals.layout = 'layouts/auth';
next();
});

Tóm tắt

Layouts và Partials không thay thế nhau — dùng cả hai:
Layouts cho khung trang (HTML, head, structure)
Partials cho các mảnh UI tái sử dụng (card, form, widget…)
Want to print your doc?
This is not the way.
Try clicking the ··· in the right corner or using a keyboard shortcut (
CtrlP
) instead.