diff --git a/app/controllers/BlogController.php b/app/controllers/BlogController.php
new file mode 100644
index 0000000..23f595e
--- /dev/null
+++ b/app/controllers/BlogController.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Controllers;
+
+use App\Model\BlogModel;
+
+class BlogController extends PageController {
+
+	public function indexAction(): void
+	{
+		$query = $this->router->request()->param('search', '');
+		$posts = $this->search($query);
+
+		$this->defineHelpers();
+
+		$this->router->service()->search = $query;
+		$this->router->service()->posts = $posts;
+		$this->router->service()->injectView = $this->views . '/partials/blog-posts.php';
+		parent::view('blog', 'Hello');
+	}
+
+	public function searchAction(): void
+	{
+		$query = $this->router->request()->param('query', '');
+		$posts = $this->search($query);
+
+		$this->defineHelpers();
+
+		$this->router->service()->posts = $posts;
+		$this->router->service()->partial($this->views . '/partials/blog-posts.php', $posts);
+	}
+
+	//-------------------------------------//
+
+	private function search(string $query): ?array
+	{
+		return BlogModel::selectAll(
+			'blog_post.*, media.filename, media.extension, page.page, section.section, log.created_at', '
+				LEFT JOIN media ON blog_post.media_id = media.id
+				LEFT JOIN page ON blog_post.page_id = page.id
+				LEFT JOIN section ON page.section_id = section.id
+				LEFT JOIN log ON blog_post.log_id = log.id
+				WHERE blog_post.archived = 0 AND
+				(blog_post.title LIKE :query OR blog_post.tag LIKE :query)
+			', [[':query', "%$query%", \PDO::PARAM_STR]]);
+	}
+
+	private function defineHelpers(): void
+	{
+		$this->router->service()->prettyTimestamp = function (string $timestamp): string {
+			$date = date_create($timestamp);
+			$date = date_format($date, 'd M Y');
+			return "<u class=\"text-decoration-none text-reset\" title=\"{$timestamp}\">{$date}</u>";
+		};
+
+		$this->router->service()->tags = function (string $tags): array {
+			// Remove empty elements via array_filter()
+			return array_filter(explode(':', $tags));
+		};
+	}
+
+
+}
diff --git a/app/model/BlogModel.php b/app/model/BlogModel.php
new file mode 100644
index 0000000..f6fa299
--- /dev/null
+++ b/app/model/BlogModel.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace App\Model;
+
+class BlogModel extends Model {
+
+	protected $table = 'blog_post';
+
+}
diff --git a/app/views/admin/cache.php b/app/views/admin/cache.php
index 9ca138d..d3600fd 100644
--- a/app/views/admin/cache.php
+++ b/app/views/admin/cache.php
@@ -1,6 +1,5 @@
 <?php
-
-use \App\Classes\Config;
+	use \App\Classes\Config;
 ?>
 <div class="content shadow p-4 mb-4">
 	<h3 class="mb-4">Cache</h3>
diff --git a/app/views/blog.php b/app/views/blog.php
new file mode 100644
index 0000000..c09f342
--- /dev/null
+++ b/app/views/blog.php
@@ -0,0 +1,17 @@
+<div class="row mt-4">
+<?php $size = $this->sideContent ? '8' : '12'; ?>
+	<div class="col-12 col-md-<?= $size; ?> col-lg-<?= $size; ?>">
+
+		<div class="input-group mb-4">
+			<input type="text" name="blog-search" id="js-blog-search" class="form-control"
+				autofocus="" placeholder="Search" value="<?= $this->search; ?>" onfocus="this.select();" data-url="<?= $this->url; ?>">
+			<div class="input-group-append">
+				<button type="button" id="js-blog-search-button" class="btn btn-dark"><i class="fa fa-search"></i> Search</button>
+			</div>
+		</div>
+
+		<div id="blog-posts">
+			<?= $this->partial($this->injectView); ?>
+		</div>
+	</div>
+</div>
diff --git a/app/views/partials/blog-posts.php b/app/views/partials/blog-posts.php
new file mode 100644
index 0000000..9654423
--- /dev/null
+++ b/app/views/partials/blog-posts.php
@@ -0,0 +1,38 @@
+<?php
+	use App\Classes\Config;
+?>
+<?php foreach ($this->posts as $post) { ?>
+		<a class="clear" href="<?= Config::c('APP_URL') . '/' . $post['section'] . '/' . $post['page']; ?>">
+			<div class="content shadow p-4 mb-4" style="min-height: 0;">
+				<div class="row">
+					<div class="col-12 <?= _exists($post, 'media_id') ? 'col-md-9' : ''; ?>">
+						<div class="d-flex flex-wrap align-items-baseline">
+							<h4 class="mr-3"><strong><?= $post['title']; ?></strong></h4>
+							<small class="mb-2 text-muted"><?= ($this->prettyTimestamp)($post['created_at']); ?></small>
+						</div>
+						<p>
+							<?= $post['content']; ?>
+						</p>
+	<?php if (_exists($post, 'tag')) { ?>
+						<small>
+							<i>
+								tags:
+		<?php $tags = ($this->tags)($post['tag']); ?>
+		<?php foreach ($tags as $key => $tag) { ?>
+			<?= $tag . (($key === array_key_last($tags)) ? '' : ', '); ?>
+		<?php } ?>
+							</i>
+						</small>
+						<div class="d-md-none mb-3"></div>
+	<?php } ?>
+					</div>
+	<?php if (_exists($post, 'media_id')) { ?>
+					<div class="col-12 col-md-3">
+						<img src="<?= Config::c('APP_URL'). '/media/' . $post['filename'] . '.' . $post['extension']; ?>"
+						     loading="lazy" class="w-100" style="height: 125px; object-fit: cover;">
+					</div>
+	<?php } ?>
+				</div>
+			</div>
+		</a>
+<?php } ?>
diff --git a/public/css/style.css b/public/css/style.css
index 0a8a1c7..3a2b4d9 100644
--- a/public/css/style.css
+++ b/public/css/style.css
@@ -57,6 +57,11 @@ nav.shadow {
 	cursor: pointer;
 }
 
+a.clear {
+	color: inherit;
+	text-decoration: none;
+}
+
 /* Anchor offset */
 h3 {
 	position: relative;
diff --git a/public/js/site.js b/public/js/site.js
index ef22a2c..13f8273 100644
--- a/public/js/site.js
+++ b/public/js/site.js
@@ -12,6 +12,39 @@ $(document).ready(function() {
 		return elementBottom > viewportTop && elementTop < viewportBottom;
 	}
 
+	//------------------------------------------//
+	// Blog search
+
+	function blogSearch(input)
+	{
+		var url = input.data("url");
+		var search = input.val();
+		window.location.href = url + '?search=' + search;
+	}
+
+	$("#js-blog-search").keydown(function(e) {
+		if (e.key == 'Enter') {
+			e.preventDefault();
+			blogSearch($(this));
+		}
+	});
+
+	$("#js-blog-search-button").click(function() {
+		blogSearch($("#js-blog-search"));
+	});
+
+	$("#js-blog-search").on("input", function() {
+		var url = $(this).data("url");
+		var search = $(this).val();
+		if (search.length == 0 || search.length >= 3) {
+			fetch(url + '/search?query=' + search)
+				.then(response => response.text())
+				.then(data => {
+					$("#blog-posts").empty().append(data);
+				});
+		}
+	});
+
 	//------------------------------------------//
 
 	// Image hover mouseenter
diff --git a/route.php b/route.php
index e12df84..8061f6c 100644
--- a/route.php
+++ b/route.php
@@ -17,6 +17,8 @@ return [
 	['/img/captcha.jpg',            'IndexController',    'captcha'],
 	['/robots.txt',                 'IndexController',    'robots'],
 	['/sitemap.xml',                'IndexController',    'sitemap'],
+	['/blog',                       'BlogController'],
+	['/blog/search',                'BlogController', 'search'],
 	['/login',                      'LoginController',    'login',    ['', 'Sign in', '']],
 	['/reset-password',             'LoginController',    'reset',    ['', 'Reset password', '']],
 	['/logout',                     'LoginController',    'logout'],