diff --git a/TODO.md b/TODO.md
index 68957b2..f3691d6 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,6 +1,5 @@
 TODO:
 
-* Pagina om één foto te bekijken
 * Taggen door gebruikers
 * Uploaden door gebruikers
 * Album management
diff --git a/controllers/ViewPhoto.php b/controllers/ViewPhoto.php
new file mode 100644
index 0000000..eafaf31
--- /dev/null
+++ b/controllers/ViewPhoto.php
@@ -0,0 +1,32 @@
+<?php
+/*****************************************************************************
+ * ViewPhoto.php
+ * Contains the view photo controller
+ *
+ * Kabuki CMS (C) 2013-2016, Aaron van Geffen
+ *****************************************************************************/
+
+class ViewPhoto extends HTMLController
+{
+	public function __construct()
+	{
+		$photo = Asset::fromSlug($_GET['slug']);
+		if (empty($photo))
+			throw new NotFoundException();
+
+		parent::__construct($photo->getTitle() . ' - ' . SITE_TITLE);
+		$page = new PhotoPage($photo->getImage());
+
+		// Exif data?
+		$exif = EXIF::fromFile($photo->getFullPath());
+		if ($exif)
+			$page->setExif($exif);
+
+		$this->page->adopt($page);
+		$this->page->setCanonicalUrl($photo->getPageUrl());
+
+		// Add an edit button to the admin bar.
+		if (Registry::get('user')->isAdmin())
+			$this->admin_bar->appendItem(BASEURL . '/editasset/?id=' . $photo->getId(), 'Edit this photo');
+	}
+}
diff --git a/models/Asset.php b/models/Asset.php
index dfc12c3..f005020 100644
--- a/models/Asset.php
+++ b/models/Asset.php
@@ -32,9 +32,7 @@ class Asset
 
 	public static function fromId($id_asset, $return_format = 'object')
 	{
-		$db = Registry::get('db');
-
-		$row = $db->queryAssoc('
+		$row = Registry::get('db')->queryAssoc('
 			SELECT *
 			FROM assets
 			WHERE id_asset = {int:id_asset}',
@@ -42,16 +40,31 @@ class Asset
 				'id_asset' => $id_asset,
 			]);
 
-		// Asset not found?
-		if (empty($row))
-			return false;
+		return empty($row) ? false : self::byRow($row, $return_format);
+	}
 
-		$row['meta'] = $db->queryPair('
+	public static function fromSlug($slug, $return_format = 'object')
+	{
+		$row = Registry::get('db')->queryAssoc('
+			SELECT *
+			FROM assets
+			WHERE slug = {string:slug}',
+			[
+				'slug' => $slug,
+			]);
+
+		return empty($row) ? false : self::byRow($row, $return_format);
+	}
+
+	public static function byRow(array $row, $return_format = 'object')
+	{
+		// Supplement with metadata.
+		$row['meta'] = Registry::get('db')->queryPair('
 			SELECT variable, value
 			FROM assets_meta
 			WHERE id_asset = {int:id_asset}',
 			[
-				'id_asset' => $id_asset,
+				'id_asset' => $row['id_asset'],
 			]);
 
 		return $return_format == 'object' ? new Asset($row) : $row;
@@ -254,6 +267,11 @@ class Asset
 		return $this->meta;
 	}
 
+	public function getFullPath()
+	{
+		return ASSETSDIR . '/' . $this->subdir . '/' . $this->filename;
+	}
+
 	public function getPath()
 	{
 		return $this->subdir;
@@ -282,6 +300,11 @@ class Asset
 		return BASEURL . '/assets/' . $this->subdir . '/' . $this->filename;
 	}
 
+	public function getPageUrl()
+	{
+		return BASEURL . '/' . $this->slug . '/';
+	}
+
 	public function getType()
 	{
 		return substr($this->mimetype, 0, strpos($this->mimetype, '/'));
diff --git a/models/Dispatcher.php b/models/Dispatcher.php
index 790e1fc..c47e9df 100644
--- a/models/Dispatcher.php
+++ b/models/Dispatcher.php
@@ -53,6 +53,12 @@ class Dispatcher
 			$_GET = array_merge($_GET, $path);
 			return new ViewPhotoAlbum();
 		}
+		// A photo for sure, then, right?
+		elseif (preg_match('~^/(?<slug>.+?)/?$~', $_SERVER['PATH_INFO'], $path))
+		{
+			$_GET = array_merge($_GET, $path);
+			return new ViewPhoto();
+		}
 		// No idea, then?
 		else
 			throw new NotFoundException();
diff --git a/models/Tag.php b/models/Tag.php
index ea9bd7c..5c1b2a6 100644
--- a/models/Tag.php
+++ b/models/Tag.php
@@ -246,7 +246,7 @@ class Tag
 
 	public function getUrl()
 	{
-		return BASEURL . '/tag/' . $this->slug . '/';
+		return BASEURL . '/' . $this->slug . '/';
 	}
 
 	public function save()
diff --git a/public/css/default.css b/public/css/default.css
index dda95c1..769d424 100644
--- a/public/css/default.css
+++ b/public/css/default.css
@@ -474,6 +474,103 @@ textarea {
 }
 
 
+/* Styling for the photo pages
+--------------------------------*/
+#photo_frame {
+	overflow: hidden;
+	text-align: center;
+}
+#photo_frame a {
+	background: #fff;
+	border: 0.9em solid #fff;
+	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	cursor: -moz-zoom-in;
+	display: inline-block;
+}
+#photo_frame a img {
+	border: none;
+	display: block;
+	height: auto;
+	width: 100%;
+}
+
+#previous_photo, #next_photo {
+	background: #fff;
+	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	color: #678FA4;
+	font-size: 3em;
+	line-height: 0.5;
+	padding: 32px 8px;
+	position: fixed;
+	text-decoration: none;
+	top: 45%;
+}
+#previous_photo em, #next_photo em {
+	position: absolute;
+	top: -1000em;
+	left: -1000em;
+}
+span#previous_photo, span#next_photo {
+	opacity: 0.25;
+}
+a#previous_photo:hover, a#next_photo:hover {
+	background: #eee;
+	color: #000;
+}
+#previous_photo {
+	left: 0;
+}
+#previous_photo:before {
+	content: '←';
+}
+#next_photo {
+	right: 0;
+}
+#next_photo:before {
+	content: '→';
+}
+
+#sub_photo h2, #sub_photo h3, #photo_exif_box h3 {
+	font: 600 20px/30px "Open Sans", sans-serif;
+	margin: 0 0 10px;
+}
+#sub_photo h3 {
+	font-size: 16px;
+}
+
+#sub_photo {
+	background: #fff;
+	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	float: left;
+	padding: 2%;
+	margin: 25px 3.5% 25px 0;
+	width: 68.5%;
+}
+
+#photo_exif_box {
+	background: #fff;
+	box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
+	margin: 25px 0 25px 0;
+	overflow: auto;
+	padding: 2%;
+	float: right;
+	width: 20%;
+}
+#photo_exif_box dt {
+	font-weight: bold;
+	float: left;
+	clear: left;
+	width: 120px;
+}
+#photo_exif_box dt:after {
+	content: ':';
+}
+#photo_exif_box dd {
+	float: left;
+	margin: 0;
+}
+
+
 /* Responsive: smartphone in portrait
 ---------------------------------------*/
 @media only screen and (max-width: 895px) {
diff --git a/public/js/photonav.js b/public/js/photonav.js
new file mode 100644
index 0000000..44e4fd3
--- /dev/null
+++ b/public/js/photonav.js
@@ -0,0 +1,72 @@
+function enableKeyDownNavigation() {
+	document.addEventListener("keydown", function (event) {
+		if (event.keyCode == 37) {
+			var target = document.getElementById("previous_photo").href;
+			if (target) {
+				event.preventDefault();
+				document.location.href = target;
+			}
+		}
+		else if (event.keyCode == 39) {
+			var target = document.getElementById("next_photo").href;
+			if (target) {
+				event.preventDefault();
+				document.location.href = target;
+			}
+		}
+	}, false);
+}
+
+function disableKeyDownPropagation(obj) {
+	for (var x = 0; x < obj.length; x++) {
+		obj[x].addEventListener("keydown", function (event) {
+			if (event.keyCode == 37 || event.keyCode == 39) {
+				event.stopPropagation();
+			}
+		});
+	}
+}
+
+function enableTouchNavigation() {
+	var x_down = null;
+	var y_down = null;
+
+	document.addEventListener('touchstart', function(event) {
+		x_down = event.touches[0].clientX;
+		y_down = event.touches[0].clientY;
+	}, false);
+
+	document.addEventListener('touchmove', function(event) {
+		if (!x_down || !y_down) {
+			return;
+		}
+
+		var x_diff = x_down - event.touches[0].clientX;
+		var y_diff = y_down - event.touches[0].clientY;
+
+		if (Math.abs(y_diff) > 50) {
+			return;
+		}
+
+		if (Math.abs(x_diff) > Math.abs(y_diff)) {
+			if (x_diff > 0) {
+				var target = document.getElementById("previous_photo").href;
+				if (target) {
+					event.preventDefault();
+					document.location.href = target;
+				}
+			} else {
+				var target = document.getElementById("next_photo").href;
+				if (target) {
+					event.preventDefault();
+					document.location.href = target;
+				}
+			}
+		}
+	}, false);
+}
+
+enableKeyDownNavigation();
+enableTouchNavigation();
+disableKeyDownPropagation(document.getElementsByTagName("textarea"));
+disableKeyDownPropagation(document.getElementsByTagName("input"));
diff --git a/templates/PhotoPage.php b/templates/PhotoPage.php
new file mode 100644
index 0000000..9ee2a09
--- /dev/null
+++ b/templates/PhotoPage.php
@@ -0,0 +1,138 @@
+<?php
+/*****************************************************************************
+ * PhotoPage.php
+ * Contains the photo page template.
+ *
+ * Kabuki CMS (C) 2013-2016, Aaron van Geffen
+ *****************************************************************************/
+
+class PhotoPage extends SubTemplate
+{
+	private $photo;
+	private $exif;
+
+	public function __construct(Image $photo)
+	{
+		$this->photo = $photo;
+	}
+
+	protected function html_content()
+	{
+		$this->photoNav();
+		$this->photo();
+
+		echo '
+				<div id="sub_photo">
+					<h2 class="entry-title">', $this->photo->getTitle(), '</h2>';
+
+		$this->taggedPeople();
+
+		echo '
+				</div>';
+
+		$this->photoMeta();
+
+		echo '
+				<script type="text/javascript" src="', BASEURL, '/js/photonav.js"></script>';
+	}
+
+	private function photo()
+	{
+		echo '
+				<div id="photo_frame">
+					<a href="', $this->photo->getUrl(), '">';
+
+		if ($this->photo->isPortrait())
+			echo '
+						<img src="', $this->photo->getThumbnailUrl(null, 960), '" alt="">';
+		else
+			echo '
+						<img src="', $this->photo->getThumbnailUrl(1280, null), '" alt="">';
+
+		echo '
+					</a>
+				</div>';
+	}
+
+	private function photoNav()
+	{
+		if (false) // $previous_post = $this->post->getPreviousPostUrl())
+			echo '
+				<a href="', $previous_post, '" id="previous_photo"><em>Previous photo</em></a>';
+		else
+			echo '
+				<span id="previous_photo"><em>Previous photo</em></span>';
+
+		if (false) //$this->post->getNextPostUrl())
+			echo '
+				<a href="', $next_post, '" id="next_photo"><em>Next photo</em></a>';
+		else
+			echo '
+				<span id="next_photo"><em>Next photo</em></span>';
+	}
+
+	private function photoMeta()
+	{
+		echo '
+				<div id="photo_exif_box">
+					<h3>EXIF</h3>
+					<dl class="photo_meta">';
+
+		if (!empty($this->exif->created_timestamp))
+			echo '
+						<dt>Date Taken</dt>
+						<dd>', date("j M Y, H:i:s", $this->exif->created_timestamp), '</dd>';
+
+		if (!empty($this->exif->camera))
+			echo '
+						<dt>Model</dt>
+						<dd>', $this->exif->camera, '</dd>';
+
+		if (!empty($this->exif->shutter_speed))
+			echo '
+						<dt>Shutter Speed</dt>
+						<dd>', $this->exif->shutterSpeedFraction(), '</dd>';
+
+		if (!empty($this->exif->aperture))
+			echo '
+						<dt>Aperture</dt>
+						<dd>f/', number_format($this->exif->aperture, 1), '</dd>';
+
+		if (!empty($this->exif->focal_length))
+			echo '
+						<dt>Focal Length</dt>
+						<dd>', $this->exif->focal_length, ' mm</dd>';
+
+		if (!empty($this->exif->iso))
+			echo '
+						<dt>ISO Speed</dt>
+						<dd>', $this->exif->iso, '</dd>';
+
+		echo '
+					</dl>
+				</div>';
+	}
+
+	private function taggedPeople()
+	{
+		echo '
+					<h3>Tags</h3>
+					<ul>';
+
+		foreach ($this->photo->getTags() as $tag)
+		{
+			echo '
+						<li>
+							<a rel="tag" title="View all posts tagged ', $tag->tag, '" href="', $tag->getUrl(), '" class="entry-tag">', $tag->tag, '</a>
+						</li>';
+		}
+
+		echo '
+					</ul>';
+	}
+
+	public function setExif(EXIF $exif)
+	{
+		$this->exif = $exif;
+	}
+}
diff --git a/templates/PhotosIndex.php b/templates/PhotosIndex.php
index 47daf87..5f353b1 100644
--- a/templates/PhotosIndex.php
+++ b/templates/PhotosIndex.php
@@ -94,7 +94,7 @@ class PhotosIndex extends SubTemplate
 					<a class="edit" href="', BASEURL, '/editasset/?id=', $image->getId(), '">Edit</a>';
 
 		echo '
-					<a href="', $image->getUrl(), '">
+					<a href="', $image->getPageUrl(), '">
 						<img src="', $image->getThumbnailUrl($width, $height, $crop, $fit), '" alt="" title="', $image->getTitle(), '">';
 
 		if ($this->show_labels)