diff --git a/app/Book.php b/app/Book.php
index 51ea226b4..4e944ce10 100644
--- a/app/Book.php
+++ b/app/Book.php
@@ -48,14 +48,6 @@ class Book extends Entity
     {
         return $this->belongsTo(Image::class, 'image_id');
     }
-    /*
-     * Get the edit url for this book.
-     * @return string
-     */
-    public function getEditUrl()
-    {
-        return $this->getUrl() . '/edit';
-    }
 
     /**
      * Get all pages within this book.
@@ -75,6 +67,15 @@ class Book extends Entity
         return $this->hasMany(Chapter::class);
     }
 
+    /**
+     * Get the shelves this book is contained within.
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     */
+    public function shelves()
+    {
+        return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
+    }
+
     /**
      * Get an excerpt of this book's description to the specified length or less.
      * @param int $length
diff --git a/app/Bookshelf.php b/app/Bookshelf.php
new file mode 100644
index 000000000..ce2acbf0c
--- /dev/null
+++ b/app/Bookshelf.php
@@ -0,0 +1,84 @@
+<?php namespace BookStack;
+
+
+class Bookshelf extends Entity
+{
+    protected $table = 'bookshelves';
+
+    public $searchFactor = 3;
+
+    protected $fillable = ['name', 'description', 'image_id'];
+
+    /**
+     * Get the books in this shelf.
+     * Should not be used directly since does not take into account permissions.
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
+     */
+    public function books()
+    {
+        return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id')->orderBy('order', 'asc');
+    }
+
+    /**
+     * Get the url for this bookshelf.
+     * @param string|bool $path
+     * @return string
+     */
+    public function getUrl($path = false)
+    {
+        if ($path !== false) {
+            return baseUrl('/shelves/' . urlencode($this->slug) . '/' . trim($path, '/'));
+        }
+        return baseUrl('/shelves/' . urlencode($this->slug));
+    }
+
+    /**
+     * Returns BookShelf cover image, if cover does not exists return default cover image.
+     * @param int $width - Width of the image
+     * @param int $height - Height of the image
+     * @return string
+     */
+    public function getBookCover($width = 440, $height = 250)
+    {
+        $default = baseUrl('/book_default_cover.png');
+        if (!$this->image_id) {
+            return $default;
+        }
+
+        try {
+            $cover = $this->cover ? baseUrl($this->cover->getThumb($width, $height, false)) : $default;
+        } catch (\Exception $err) {
+            $cover = $default;
+        }
+        return $cover;
+    }
+
+    /**
+     * Get the cover image of the book
+     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
+     */
+    public function cover()
+    {
+        return $this->belongsTo(Image::class, 'image_id');
+    }
+
+    /**
+     * Get an excerpt of this book's description to the specified length or less.
+     * @param int $length
+     * @return string
+     */
+    public function getExcerpt($length = 100)
+    {
+        $description = $this->description;
+        return strlen($description) > $length ? substr($description, 0, $length-3) . '...' : $description;
+    }
+
+    /**
+     * Return a generalised, common raw query that can be 'unioned' across entities.
+     * @return string
+     */
+    public function entityRawQuery()
+    {
+        return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
+    }
+}
diff --git a/app/Entity.php b/app/Entity.php
index 5d4449f2b..fb1c6d48b 100644
--- a/app/Entity.php
+++ b/app/Entity.php
@@ -152,7 +152,7 @@ class Entity extends Ownable
      */
     public static function getEntityInstance($type)
     {
-        $types = ['Page', 'Book', 'Chapter'];
+        $types = ['Page', 'Book', 'Chapter', 'Bookshelf'];
         $className = str_replace([' ', '-', '_'], '', ucwords($type));
         if (!in_array($className, $types)) {
             return null;
diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php
new file mode 100644
index 000000000..d1752d180
--- /dev/null
+++ b/app/Http/Controllers/BookshelfController.php
@@ -0,0 +1,244 @@
+<?php namespace BookStack\Http\Controllers;
+
+use Activity;
+use BookStack\Book;
+use BookStack\Bookshelf;
+use BookStack\Repos\EntityRepo;
+use BookStack\Repos\UserRepo;
+use BookStack\Services\ExportService;
+use Illuminate\Http\Request;
+use Illuminate\Http\Response;
+use Views;
+
+class BookshelfController extends Controller
+{
+
+    protected $entityRepo;
+    protected $userRepo;
+    protected $exportService;
+
+    /**
+     * BookController constructor.
+     * @param EntityRepo $entityRepo
+     * @param UserRepo $userRepo
+     * @param ExportService $exportService
+     */
+    public function __construct(EntityRepo $entityRepo, UserRepo $userRepo, ExportService $exportService)
+    {
+        $this->entityRepo = $entityRepo;
+        $this->userRepo = $userRepo;
+        $this->exportService = $exportService;
+        parent::__construct();
+    }
+
+    /**
+     * Display a listing of the book.
+     * @return Response
+     */
+    public function index()
+    {
+        $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18);
+        $recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('bookshelf', 4, 0) : false;
+        $popular = $this->entityRepo->getPopular('bookshelf', 4, 0);
+        $new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0);
+        $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+
+        $this->setPageTitle(trans('entities.shelves'));
+        return view('shelves/index', [
+            'shelves' => $shelves,
+            'recents' => $recents,
+            'popular' => $popular,
+            'new' => $new,
+            'shelvesViewType' => $shelvesViewType
+        ]);
+    }
+
+    /**
+     * Show the form for creating a new bookshelf.
+     * @return Response
+     */
+    public function create()
+    {
+        $this->checkPermission('bookshelf-create-all');
+        $books = $this->entityRepo->getAll('book', false, 'update');
+        $this->setPageTitle(trans('entities.shelves_create'));
+        return view('shelves/create', ['books' => $books]);
+    }
+
+    /**
+     * Store a newly created bookshelf in storage.
+     * @param  Request $request
+     * @return Response
+     */
+    public function store(Request $request)
+    {
+        $this->checkPermission('bookshelf-create-all');
+        $this->validate($request, [
+            'name' => 'required|string|max:255',
+            'description' => 'string|max:1000',
+        ]);
+
+        $bookshelf = $this->entityRepo->createFromInput('bookshelf', $request->all());
+        $this->entityRepo->updateShelfBooks($bookshelf, $request->get('books', ''));
+        Activity::add($bookshelf, 'bookshelf_create');
+
+        return redirect($bookshelf->getUrl());
+    }
+
+
+    /**
+     * Display the specified bookshelf.
+     * @param String $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function show(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('book-view', $bookshelf);
+
+        $books = $this->entityRepo->getBookshelfChildren($bookshelf);
+        Views::add($bookshelf);
+
+        $this->setPageTitle($bookshelf->getShortName());
+        return view('shelves/show', [
+            'shelf' => $bookshelf,
+            'books' => $books,
+            'activity' => Activity::entityActivity($bookshelf, 20, 0)
+        ]);
+    }
+
+    /**
+     * Show the form for editing the specified bookshelf.
+     * @param $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function edit(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-update', $bookshelf);
+
+        $shelfBooks = $this->entityRepo->getBookshelfChildren($bookshelf);
+        $shelfBookIds = $shelfBooks->pluck('id');
+        $books = $this->entityRepo->getAll('book', false, 'update');
+        $books = $books->filter(function ($book) use ($shelfBookIds) {
+             return !$shelfBookIds->contains($book->id);
+        });
+
+        $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $bookshelf->getShortName()]));
+        return view('shelves/edit', [
+            'shelf' => $bookshelf,
+            'books' => $books,
+            'shelfBooks' => $shelfBooks,
+        ]);
+    }
+
+
+    /**
+     * Update the specified bookshelf in storage.
+     * @param  Request $request
+     * @param string $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function update(Request $request, string $slug)
+    {
+        $shelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-update', $shelf);
+        $this->validate($request, [
+            'name' => 'required|string|max:255',
+            'description' => 'string|max:1000',
+        ]);
+
+         $shelf = $this->entityRepo->updateFromInput('bookshelf', $shelf, $request->all());
+         $this->entityRepo->updateShelfBooks($shelf, $request->get('books', ''));
+         Activity::add($shelf, 'bookshelf_update');
+
+         return redirect($shelf->getUrl());
+    }
+
+
+    /**
+     * Shows the page to confirm deletion
+     * @param $slug
+     * @return \Illuminate\View\View
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function showDelete(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-delete', $bookshelf);
+
+        $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $bookshelf->getShortName()]));
+        return view('shelves/delete', ['shelf' => $bookshelf]);
+    }
+
+    /**
+     * Remove the specified bookshelf from storage.
+     * @param string $slug
+     * @return Response
+     * @throws \BookStack\Exceptions\NotFoundException
+     * @throws \Throwable
+     */
+    public function destroy(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */
+        $this->checkOwnablePermission('bookshelf-delete', $bookshelf);
+        Activity::addMessage('bookshelf_delete', 0, $bookshelf->name);
+        $this->entityRepo->destroyBookshelf($bookshelf);
+        return redirect('/shelves');
+    }
+
+    /**
+     * Show the Restrictions view.
+     * @param $slug
+     * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function showRestrict(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug);
+        $this->checkOwnablePermission('restrictions-manage', $bookshelf);
+
+        $roles = $this->userRepo->getRestrictableRoles();
+        return view('shelves.restrictions', [
+            'shelf' => $bookshelf,
+            'roles' => $roles
+        ]);
+    }
+
+    /**
+     * Set the restrictions for this bookshelf.
+     * @param $slug
+     * @param Request $request
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function restrict(string $slug, Request $request)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug);
+        $this->checkOwnablePermission('restrictions-manage', $bookshelf);
+
+        $this->entityRepo->updateEntityPermissionsFromRequest($request, $bookshelf);
+        session()->flash('success', trans('entities.shelves_permissions_updated'));
+        return redirect($bookshelf->getUrl());
+    }
+
+    /**
+     * Copy the permissions of a bookshelf to the child books.
+     * @param string $slug
+     * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
+     * @throws \BookStack\Exceptions\NotFoundException
+     */
+    public function copyPermissions(string $slug)
+    {
+        $bookshelf = $this->entityRepo->getBySlug('bookshelf', $slug);
+        $this->checkOwnablePermission('restrictions-manage', $bookshelf);
+
+        $updateCount = $this->entityRepo->copyBookshelfPermissions($bookshelf);
+        session()->flash('success', trans('entities.shelves_copy_permission_success', ['count' => $updateCount]));
+        return redirect($bookshelf->getUrl());
+    }
+
+}
diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php
index 2077f6888..e47250318 100644
--- a/app/Http/Controllers/HomeController.php
+++ b/app/Http/Controllers/HomeController.php
@@ -33,42 +33,42 @@ class HomeController extends Controller
         $recents = $this->signedIn ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 12*$recentFactor);
         $recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 12);
 
-
-        $customHomepage = false;
-        $books = false;
-        $booksViewType = false;
-
-        // Check book homepage
-        $bookHomepageSetting = setting('app-book-homepage');
-        if ($bookHomepageSetting) {
-            $books = $this->entityRepo->getAllPaginated('book', 18);
-            $booksViewType = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books', 'list'));
-        } else {
-            // Check custom homepage
-            $homepageSetting = setting('app-homepage');
-            if ($homepageSetting) {
-                $id = intval(explode(':', $homepageSetting)[0]);
-                $customHomepage = $this->entityRepo->getById('page', $id, false, true);
-                $this->entityRepo->renderPage($customHomepage, true);
-            }
+        $homepageOptions = ['default', 'books', 'bookshelves', 'page'];
+        $homepageOption = setting('app-homepage-type', 'default');
+        if (!in_array($homepageOption, $homepageOptions)) {
+            $homepageOption = 'default';
         }
 
-        $view = 'home';
-        if ($bookHomepageSetting) {
-            $view = 'home-book';
-        } else if ($customHomepage) {
-            $view = 'home-custom';
-        }
-
-        return view('common/' . $view, [
+        $commonData = [
             'activity' => $activity,
             'recents' => $recents,
             'recentlyUpdatedPages' => $recentlyUpdatedPages,
             'draftPages' => $draftPages,
-            'customHomepage' => $customHomepage,
-            'books' => $books,
-            'booksViewType' => $booksViewType
-        ]);
+        ];
+
+        if ($homepageOption === 'bookshelves') {
+            $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18);
+            $shelvesViewType = setting()->getUser($this->currentUser, 'bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+            $data = array_merge($commonData, ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType]);
+            return view('common.home-shelves', $data);
+        }
+
+        if ($homepageOption === 'books') {
+            $books = $this->entityRepo->getAllPaginated('book', 18);
+            $booksViewType = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books', 'list'));
+            $data = array_merge($commonData, ['books' => $books, 'booksViewType' => $booksViewType]);
+            return view('common.home-book', $data);
+        }
+
+        if ($homepageOption === 'page') {
+            $homepageSetting = setting('app-homepage', '0:');
+            $id = intval(explode(':', $homepageSetting)[0]);
+            $customHomepage = $this->entityRepo->getById('page', $id, false, true);
+            $this->entityRepo->renderPage($customHomepage, true);
+            return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage]));
+        }
+
+        return view('common.home', $commonData);
     }
 
     /**
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index d50baa86f..f6bd13e6f 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -252,7 +252,7 @@ class UserController extends Controller
             return $this->currentUser->id == $id;
         });
 
-        $viewType = $request->get('book_view_type');
+        $viewType = $request->get('view_type');
         if (!in_array($viewType, ['grid', 'list'])) {
             $viewType = 'list';
         }
@@ -262,4 +262,27 @@ class UserController extends Controller
 
         return redirect()->back(302, [], "/settings/users/$id");
     }
+
+    /**
+     * Update the user's preferred shelf-list display setting.
+     * @param $id
+     * @param Request $request
+     * @return \Illuminate\Http\RedirectResponse
+     */
+    public function switchShelfView($id, Request $request)
+    {
+        $this->checkPermissionOr('users-manage', function () use ($id) {
+            return $this->currentUser->id == $id;
+        });
+
+        $viewType = $request->get('view_type');
+        if (!in_array($viewType, ['grid', 'list'])) {
+            $viewType = 'list';
+        }
+
+        $user = $this->user->findOrFail($id);
+        setting()->putUser($user, 'bookshelves_view_type', $viewType);
+
+        return redirect()->back(302, [], "/settings/users/$id");
+    }
 }
diff --git a/app/Repos/EntityRepo.php b/app/Repos/EntityRepo.php
index bdd1e37b1..11f89fc34 100644
--- a/app/Repos/EntityRepo.php
+++ b/app/Repos/EntityRepo.php
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Repos;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\Exceptions\NotFoundException;
@@ -18,6 +19,10 @@ use Illuminate\Support\Collection;
 
 class EntityRepo
 {
+    /**
+     * @var Bookshelf
+     */
+    public $bookshelf;
 
     /**
      * @var Book $book
@@ -67,6 +72,7 @@ class EntityRepo
 
     /**
      * EntityRepo constructor.
+     * @param Bookshelf $bookshelf
      * @param Book $book
      * @param Chapter $chapter
      * @param Page $page
@@ -77,6 +83,7 @@ class EntityRepo
      * @param SearchService $searchService
      */
     public function __construct(
+        Bookshelf $bookshelf,
         Book $book,
         Chapter $chapter,
         Page $page,
@@ -86,11 +93,13 @@ class EntityRepo
         TagRepo $tagRepo,
         SearchService $searchService
     ) {
+        $this->bookshelf = $bookshelf;
         $this->book = $book;
         $this->chapter = $chapter;
         $this->page = $page;
         $this->pageRevision = $pageRevision;
         $this->entities = [
+            'bookshelf' => $this->bookshelf,
             'page' => $this->page,
             'chapter' => $this->chapter,
             'book' => $this->book
@@ -331,6 +340,17 @@ class EntityRepo
             ->skip($count * $page)->take($count)->get();
     }
 
+    /**
+     * Get the child items for a chapter sorted by priority but
+     * with draft items floated to the top.
+     * @param Bookshelf $bookshelf
+     * @return \Illuminate\Database\Eloquent\Collection|static[]
+     */
+    public function getBookshelfChildren(Bookshelf $bookshelf)
+    {
+        return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
+    }
+
     /**
      * Get all child objects of a book.
      * Returns a sorted collection of Pages and Chapters.
@@ -533,6 +553,28 @@ class EntityRepo
         return $entityModel;
     }
 
+    /**
+     * Sync the books assigned to a shelf from a comma-separated list
+     * of book IDs.
+     * @param Bookshelf $shelf
+     * @param string $books
+     */
+    public function updateShelfBooks(Bookshelf $shelf, string $books)
+    {
+        $ids = explode(',', $books);
+
+        // Check books exist and match ordering
+        $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
+        $syncData = [];
+        foreach ($ids as $index => $id) {
+            if ($bookIds->contains($id)) {
+                $syncData[$id] = ['order' => $index];
+            }
+        }
+
+        $shelf->books()->sync($syncData);
+    }
+
     /**
      * Change the book that an entity belongs to.
      * @param string $type
@@ -1154,9 +1196,22 @@ class EntityRepo
         $this->permissionService->buildJointPermissionsForEntity($book);
     }
 
+    /**
+     * Destroy a bookshelf instance
+     * @param Bookshelf $shelf
+     * @throws \Throwable
+     */
+    public function destroyBookshelf(Bookshelf $shelf)
+    {
+        $this->destroyEntityCommonRelations($shelf);
+        $shelf->delete();
+    }
+
     /**
      * Destroy the provided book and all its child entities.
      * @param Book $book
+     * @throws NotifyException
+     * @throws \Throwable
      */
     public function destroyBook(Book $book)
     {
@@ -1166,17 +1221,14 @@ class EntityRepo
         foreach ($book->chapters as $chapter) {
             $this->destroyChapter($chapter);
         }
-        \Activity::removeEntity($book);
-        $book->views()->delete();
-        $book->permissions()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($book);
-        $this->searchService->deleteEntityTerms($book);
+        $this->destroyEntityCommonRelations($book);
         $book->delete();
     }
 
     /**
      * Destroy a chapter and its relations.
      * @param Chapter $chapter
+     * @throws \Throwable
      */
     public function destroyChapter(Chapter $chapter)
     {
@@ -1186,11 +1238,7 @@ class EntityRepo
                 $page->save();
             }
         }
-        \Activity::removeEntity($chapter);
-        $chapter->views()->delete();
-        $chapter->permissions()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($chapter);
-        $this->searchService->deleteEntityTerms($chapter);
+        $this->destroyEntityCommonRelations($chapter);
         $chapter->delete();
     }
 
@@ -1198,23 +1246,18 @@ class EntityRepo
      * Destroy a given page along with its dependencies.
      * @param Page $page
      * @throws NotifyException
+     * @throws \Throwable
      */
     public function destroyPage(Page $page)
     {
-        \Activity::removeEntity($page);
-        $page->views()->delete();
-        $page->tags()->delete();
-        $page->revisions()->delete();
-        $page->permissions()->delete();
-        $this->permissionService->deleteJointPermissionsForEntity($page);
-        $this->searchService->deleteEntityTerms($page);
-
         // Check if set as custom homepage
         $customHome = setting('app-homepage', '0:');
         if (intval($page->id) === intval(explode(':', $customHome)[0])) {
             throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
         }
 
+        $this->destroyEntityCommonRelations($page);
+
         // Delete Attached Files
         $attachmentService = app(AttachmentService::class);
         foreach ($page->attachments as $attachment) {
@@ -1223,4 +1266,46 @@ class EntityRepo
 
         $page->delete();
     }
+
+    /**
+     * Destroy or handle the common relations connected to an entity.
+     * @param Entity $entity
+     * @throws \Throwable
+     */
+    protected function destroyEntityCommonRelations(Entity $entity)
+    {
+        \Activity::removeEntity($entity);
+        $entity->views()->delete();
+        $entity->permissions()->delete();
+        $entity->tags()->delete();
+        $entity->comments()->delete();
+        $this->permissionService->deleteJointPermissionsForEntity($entity);
+        $this->searchService->deleteEntityTerms($entity);
+    }
+
+    /**
+     * Copy the permissions of a bookshelf to all child books.
+     * Returns the number of books that had permissions updated.
+     * @param Bookshelf $bookshelf
+     * @return int
+     * @throws \Throwable
+     */
+    public function copyBookshelfPermissions(Bookshelf $bookshelf)
+    {
+        $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
+        $shelfBooks = $bookshelf->books()->get();
+        $updatedBookCount = 0;
+
+        foreach ($shelfBooks as $book) {
+            if (!userCan('restrictions-manage', $book)) continue;
+            $book->permissions()->delete();
+            $book->restricted = $bookshelf->restricted;
+            $book->permissions()->createMany($shelfPermissions);
+            $book->save();
+            $this->permissionService->buildJointPermissionsForEntity($book);
+            $updatedBookCount++;
+        }
+
+        return $updatedBookCount;
+    }
 }
diff --git a/app/Repos/PermissionsRepo.php b/app/Repos/PermissionsRepo.php
index 6f7ea1dc8..68c9270be 100644
--- a/app/Repos/PermissionsRepo.php
+++ b/app/Repos/PermissionsRepo.php
@@ -80,7 +80,7 @@ class PermissionsRepo
 
     /**
      * Updates an existing role.
-     * Ensure Admin role always has all permissions.
+     * Ensure Admin role always have core permissions.
      * @param $roleId
      * @param $roleData
      * @throws PermissionsException
@@ -90,13 +90,18 @@ class PermissionsRepo
         $role = $this->role->findOrFail($roleId);
 
         $permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
-        $this->assignRolePermissions($role, $permissions);
-
         if ($role->system_name === 'admin') {
-            $permissions = $this->permission->all()->pluck('id')->toArray();
-            $role->permissions()->sync($permissions);
+            $permissions = array_merge($permissions, [
+                'users-manage',
+                'user-roles-manage',
+                'restrictions-manage-all',
+                'restrictions-manage-own',
+                'settings-manage',
+            ]);
         }
 
+        $this->assignRolePermissions($role, $permissions);
+
         $role->fill($roleData);
         $role->save();
         $this->permissionService->buildJointPermissionForRole($role);
diff --git a/app/Services/PermissionService.php b/app/Services/PermissionService.php
index 0dd316b34..dade68290 100644
--- a/app/Services/PermissionService.php
+++ b/app/Services/PermissionService.php
@@ -1,6 +1,7 @@
 <?php namespace BookStack\Services;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\EntityPermission;
@@ -25,6 +26,7 @@ class PermissionService
     public $book;
     public $chapter;
     public $page;
+    public $bookshelf;
 
     protected $db;
 
@@ -38,22 +40,26 @@ class PermissionService
      * PermissionService constructor.
      * @param JointPermission $jointPermission
      * @param EntityPermission $entityPermission
+     * @param Role $role
      * @param Connection $db
+     * @param Bookshelf $bookshelf
      * @param Book $book
      * @param Chapter $chapter
      * @param Page $page
-     * @param Role $role
      */
-    public function __construct(JointPermission $jointPermission, EntityPermission $entityPermission, Connection $db, Book $book, Chapter $chapter, Page $page, Role $role)
+    public function __construct(
+        JointPermission $jointPermission, EntityPermission $entityPermission, Role $role, Connection $db,
+        Bookshelf $bookshelf, Book $book, Chapter $chapter, Page $page
+    )
     {
         $this->db = $db;
         $this->jointPermission = $jointPermission;
         $this->entityPermission = $entityPermission;
         $this->role = $role;
+        $this->bookshelf = $bookshelf;
         $this->book = $book;
         $this->chapter = $chapter;
         $this->page = $page;
-        // TODO - Update so admin still goes through filters
     }
 
     /**
@@ -159,6 +165,12 @@ class PermissionService
         $this->bookFetchQuery()->chunk(5, function ($books) use ($roles) {
             $this->buildJointPermissionsForBooks($books, $roles);
         });
+
+        // Chunk through all bookshelves
+        $this->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+            ->chunk(50, function ($shelves) use ($roles) {
+            $this->buildJointPermissionsForShelves($shelves, $roles);
+        });
     }
 
     /**
@@ -174,6 +186,20 @@ class PermissionService
         }]);
     }
 
+    /**
+     * @param Collection $shelves
+     * @param array $roles
+     * @param bool $deleteOld
+     * @throws \Throwable
+     */
+    protected function buildJointPermissionsForShelves($shelves, $roles, $deleteOld = false)
+    {
+        if ($deleteOld) {
+            $this->deleteManyJointPermissionsForEntities($shelves->all());
+        }
+        $this->createManyJointPermissions($shelves, $roles);
+    }
+
     /**
      * Build joint permissions for an array of books
      * @param Collection $books
@@ -204,6 +230,7 @@ class PermissionService
     /**
      * Rebuild the entity jointPermissions for a particular entity.
      * @param Entity $entity
+     * @throws \Throwable
      */
     public function buildJointPermissionsForEntity(Entity $entity)
     {
@@ -214,7 +241,9 @@ class PermissionService
             return;
         }
 
-        $entities[] = $entity->book;
+        if ($entity->book) {
+            $entities[] = $entity->book;
+        }
 
         if ($entity->isA('page') && $entity->chapter_id) {
             $entities[] = $entity->chapter;
@@ -226,13 +255,13 @@ class PermissionService
             }
         }
 
-        $this->deleteManyJointPermissionsForEntities($entities);
         $this->buildJointPermissionsForEntities(collect($entities));
     }
 
     /**
      * Rebuild the entity jointPermissions for a collection of entities.
      * @param Collection $entities
+     * @throws \Throwable
      */
     public function buildJointPermissionsForEntities(Collection $entities)
     {
@@ -254,6 +283,12 @@ class PermissionService
         $this->bookFetchQuery()->chunk(20, function ($books) use ($roles) {
             $this->buildJointPermissionsForBooks($books, $roles);
         });
+
+        // Chunk through all bookshelves
+        $this->bookshelf->newQuery()->select(['id', 'restricted', 'created_by'])
+            ->chunk(50, function ($shelves) use ($roles) {
+                $this->buildJointPermissionsForShelves($shelves, $roles);
+            });
     }
 
     /**
@@ -412,7 +447,7 @@ class PermissionService
             return $this->createJointPermissionDataArray($entity, $role, $action, $hasAccess, $hasAccess);
         }
 
-        if ($entity->isA('book')) {
+        if ($entity->isA('book') || $entity->isA('bookshelf')) {
             return $this->createJointPermissionDataArray($entity, $role, $action, $roleHasPermission, $roleHasPermissionOwn);
         }
 
@@ -484,11 +519,6 @@ class PermissionService
      */
     public function checkOwnableUserAccess(Ownable $ownable, $permission)
     {
-        if ($this->isAdmin()) {
-            $this->clean();
-            return true;
-        }
-
         $explodedPermission = explode('-', $permission);
 
         $baseQuery = $ownable->where('id', '=', $ownable->id);
@@ -581,17 +611,16 @@ class PermissionService
         $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
             ->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
 
-        if (!$this->isAdmin()) {
-            $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
-                ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
-                ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
-                ->where(function ($query) {
-                    $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
-                        $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
-                    });
+        // Add joint permission filter
+        $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
+            ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
+            ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
+            ->where(function ($query) {
+                $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
+                    $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
                 });
-            $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
-        }
+            });
+        $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
 
         $query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
         $this->clean();
@@ -619,11 +648,6 @@ class PermissionService
             });
         }
 
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
-
         $this->currentAction = $action;
         return $this->entityRestrictionQuery($query);
     }
@@ -639,10 +663,6 @@ class PermissionService
      */
     public function filterRestrictedEntityRelations($query, $tableName, $entityIdColumn, $entityTypeColumn, $action = 'view')
     {
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
 
         $this->currentAction = $action;
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn, 'entityTypeColumn' => $entityTypeColumn];
@@ -675,11 +695,6 @@ class PermissionService
      */
     public function filterRelatedPages($query, $tableName, $entityIdColumn)
     {
-        if ($this->isAdmin()) {
-            $this->clean();
-            return $query;
-        }
-
         $this->currentAction = 'view';
         $tableDetails = ['tableName' => $tableName, 'entityIdColumn' => $entityIdColumn];
 
@@ -704,19 +719,6 @@ class PermissionService
         return $q;
     }
 
-    /**
-     * Check if the current user is an admin.
-     * @return bool
-     */
-    private function isAdmin()
-    {
-        if ($this->isAdminUser === null) {
-            $this->isAdminUser = ($this->currentUser()->id !== null) ? $this->currentUser()->hasSystemRole('admin') : false;
-        }
-
-        return $this->isAdminUser;
-    }
-
     /**
      * Get the current user
      * @return User
diff --git a/database/factories/ModelFactory.php b/database/factories/ModelFactory.php
index c68f5c1e1..3d6ed1d63 100644
--- a/database/factories/ModelFactory.php
+++ b/database/factories/ModelFactory.php
@@ -21,6 +21,14 @@ $factory->define(BookStack\User::class, function ($faker) {
     ];
 });
 
+$factory->define(BookStack\Bookshelf::class, function ($faker) {
+    return [
+        'name' => $faker->sentence,
+        'slug' => str_random(10),
+        'description' => $faker->paragraph
+    ];
+});
+
 $factory->define(BookStack\Book::class, function ($faker) {
     return [
         'name' => $faker->sentence,
diff --git a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php
index 4c1b43c4e..ce11f7b88 100644
--- a/database/migrations/2016_04_20_192649_create_joint_permissions_table.php
+++ b/database/migrations/2016_04_20_192649_create_joint_permissions_table.php
@@ -74,10 +74,6 @@ class CreateJointPermissionsTable extends Migration
 
         // Update admin role with system name
         DB::table('roles')->where('name', '=', 'admin')->update(['system_name' => 'admin']);
-
-        // Generate the new entity jointPermissions
-        $restrictionService = app(\BookStack\Services\PermissionService::class);
-        $restrictionService->buildJointPermissions();
     }
 
     /**
diff --git a/database/migrations/2018_08_04_115700_create_bookshelves_table.php b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
new file mode 100644
index 000000000..e92b0edef
--- /dev/null
+++ b/database/migrations/2018_08_04_115700_create_bookshelves_table.php
@@ -0,0 +1,101 @@
+<?php
+
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Database\Migrations\Migration;
+
+class CreateBookshelvesTable extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::create('bookshelves', function (Blueprint $table) {
+            $table->increments('id');
+            $table->string('name', 200);
+            $table->string('slug', 200);
+            $table->text('description');
+            $table->integer('created_by')->nullable()->default(null);
+            $table->integer('updated_by')->nullable()->default(null);
+            $table->boolean('restricted')->default(false);
+            $table->integer('image_id')->nullable()->default(null);
+            $table->timestamps();
+
+            $table->index('slug');
+            $table->index('created_by');
+            $table->index('updated_by');
+            $table->index('restricted');
+        });
+
+        Schema::create('bookshelves_books', function (Blueprint $table) {
+            $table->integer('bookshelf_id')->unsigned();
+            $table->integer('book_id')->unsigned();
+            $table->integer('order')->unsigned();
+
+            $table->foreign('bookshelf_id')->references('id')->on('bookshelves')
+                ->onUpdate('cascade')->onDelete('cascade');
+            $table->foreign('book_id')->references('id')->on('books')
+                ->onUpdate('cascade')->onDelete('cascade');
+
+            $table->primary(['bookshelf_id', 'book_id']);
+        });
+
+        // Copy existing role permissions from Books
+        $ops = ['View All', 'View Own', 'Create All', 'Create Own', 'Update All', 'Update Own', 'Delete All', 'Delete Own'];
+        foreach ($ops as $op) {
+            $dbOpName = strtolower(str_replace(' ', '-', $op));
+            $roleIdsWithBookPermission = DB::table('role_permissions')
+                ->leftJoin('permission_role', 'role_permissions.id', '=', 'permission_role.permission_id')
+                ->leftJoin('roles', 'roles.id', '=', 'permission_role.role_id')
+                ->where('role_permissions.name', '=', 'book-' . $dbOpName)->get(['roles.id'])->pluck('id');
+
+            $permId = DB::table('role_permissions')->insertGetId([
+                'name' => 'bookshelf-' . $dbOpName,
+                'display_name' => $op . ' ' . 'BookShelves',
+                'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
+                'updated_at' => \Carbon\Carbon::now()->toDateTimeString()
+            ]);
+
+            $rowsToInsert = $roleIdsWithBookPermission->map(function($roleId) use ($permId) {
+                return [
+                    'role_id' => $roleId,
+                    'permission_id' => $permId
+                ];
+            })->toArray();
+
+            // Assign view permission to all current roles
+            DB::table('permission_role')->insert($rowsToInsert);
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        // Drop created permissions
+        $ops = ['bookshelf-create-all','bookshelf-create-own','bookshelf-delete-all','bookshelf-delete-own','bookshelf-update-all','bookshelf-update-own','bookshelf-view-all','bookshelf-view-own'];
+
+        $permissionIds = DB::table('role_permissions')->whereIn('name', $ops)
+            ->get(['id'])->pluck('id')->toArray();
+        DB::table('permission_role')->whereIn('permission_id', $permissionIds)->delete();
+        DB::table('role_permissions')->whereIn('id', $permissionIds)->delete();
+
+        // Drop shelves table
+        Schema::dropIfExists('bookshelves_books');
+        Schema::dropIfExists('bookshelves');
+
+        // Drop related polymorphic items
+        DB::table('activities')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('views')->where('viewable_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('entity_permissions')->where('restrictable_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('tags')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('search_terms')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+        DB::table('comments')->where('entity_type', '=', 'BookStack\Bookshelf')->delete();
+    }
+}
diff --git a/database/seeds/DummyContentSeeder.php b/database/seeds/DummyContentSeeder.php
index 41ac6650d..dcf589352 100644
--- a/database/seeds/DummyContentSeeder.php
+++ b/database/seeds/DummyContentSeeder.php
@@ -21,23 +21,29 @@ class DummyContentSeeder extends Seeder
         $role = \BookStack\Role::getRole('viewer');
         $viewerUser->attachRole($role);
 
-        factory(\BookStack\Book::class, 5)->create(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id])
-            ->each(function($book) use ($editorUser) {
-                $chapters = factory(\BookStack\Chapter::class, 3)->create(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id])
-                    ->each(function($chapter) use ($editorUser, $book){
-                        $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'book_id' => $book->id]);
+        $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id];
+
+        factory(\BookStack\Book::class, 5)->create($byData)
+            ->each(function($book) use ($editorUser, $byData) {
+                $chapters = factory(\BookStack\Chapter::class, 3)->create($byData)
+                    ->each(function($chapter) use ($editorUser, $book, $byData){
+                        $pages = factory(\BookStack\Page::class, 3)->make(array_merge($byData, ['book_id' => $book->id]));
                         $chapter->pages()->saveMany($pages);
                     });
-                $pages = factory(\BookStack\Page::class, 3)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+                $pages = factory(\BookStack\Page::class, 3)->make($byData);
                 $book->chapters()->saveMany($chapters);
                 $book->pages()->saveMany($pages);
             });
 
-        $largeBook = factory(\BookStack\Book::class)->create(['name' => 'Large book' . str_random(10), 'created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $pages = factory(\BookStack\Page::class, 200)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
-        $chapters = factory(\BookStack\Chapter::class, 50)->make(['created_by' => $editorUser->id, 'updated_by' => $editorUser->id]);
+        $largeBook = factory(\BookStack\Book::class)->create(array_merge($byData, ['name' => 'Large book' . str_random(10)]));
+        $pages = factory(\BookStack\Page::class, 200)->make($byData);
+        $chapters = factory(\BookStack\Chapter::class, 50)->make($byData);
         $largeBook->pages()->saveMany($pages);
         $largeBook->chapters()->saveMany($chapters);
+
+        $shelves = factory(\BookStack\Bookshelf::class, 10)->create($byData);
+        $largeBook->shelves()->attach($shelves->pluck('id'));
+
         app(\BookStack\Services\PermissionService::class)->buildJointPermissions();
         app(\BookStack\Services\SearchService::class)->indexAllEntities();
     }
diff --git a/package-lock.json b/package-lock.json
index ec4da5ce2..f8c43993b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -5857,6 +5857,21 @@
       "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.3.1.tgz",
       "integrity": "sha512-Ubldcmxp5np52/ENotGxlLe6aGMvmF4R8S6tZjsP6Knsaxd/xp3Zrh50cG93lR6nPXyUFwzN3ZSOQI0wRJNdGg=="
     },
+    "jquery-sortable": {
+      "version": "0.9.13",
+      "resolved": "https://registry.npmjs.org/jquery-sortable/-/jquery-sortable-0.9.13.tgz",
+      "integrity": "sha1-HL+2VQE6B0c3BXHwbiL1JKAP+6I=",
+      "requires": {
+        "jquery": "^2.1.2"
+      },
+      "dependencies": {
+        "jquery": {
+          "version": "2.2.4",
+          "resolved": "https://registry.npmjs.org/jquery/-/jquery-2.2.4.tgz",
+          "integrity": "sha1-LInWiJterFIqfuoywUUhVZxsvwI="
+        }
+      }
+    },
     "js-base64": {
       "version": "2.4.3",
       "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.3.tgz",
diff --git a/package.json b/package.json
index 12d972cf9..58f2dad5e 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
     "codemirror": "^5.26.0",
     "dropzone": "^5.4.0",
     "jquery": "^3.3.1",
+    "jquery-sortable": "^0.9.13",
     "markdown-it": "^8.3.1",
     "markdown-it-task-lists": "^2.0.0",
     "vue": "^2.2.6",
diff --git a/resources/assets/icons/bookshelf.svg b/resources/assets/icons/bookshelf.svg
new file mode 100644
index 000000000..03da68f96
--- /dev/null
+++ b/resources/assets/icons/bookshelf.svg
@@ -0,0 +1,2 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="none" d="M0 0h24v24H0V0z"/><path d="M1.088 2.566h17.42v17.42H1.088z" fill="none"/><path d="M4 20.058h15.892V22H4z"/><path d="M2.902 1.477h17.42v17.42H2.903z" fill="none"/><g><path d="M6.658 3.643V18h-2.38V3.643zM11.326 3.643V18H8.947V3.643zM14.722 3.856l5.613 13.214-2.19.93-5.613-13.214z"/></g></svg>
+
diff --git a/resources/assets/js/components/homepage-control.js b/resources/assets/js/components/homepage-control.js
new file mode 100644
index 000000000..e1f66a592
--- /dev/null
+++ b/resources/assets/js/components/homepage-control.js
@@ -0,0 +1,22 @@
+
+class HomepageControl {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.typeControl = elem.querySelector('[name="setting-app-homepage-type"]');
+        this.pagePickerContainer = elem.querySelector('[page-picker-container]');
+
+        this.typeControl.addEventListener('change', this.controlPagePickerVisibility.bind(this));
+        this.controlPagePickerVisibility();
+    }
+
+    controlPagePickerVisibility() {
+        const showPagePicker = this.typeControl.value === 'page';
+        this.pagePickerContainer.style.display = (showPagePicker ? 'block' : 'none');
+    }
+
+
+
+}
+
+module.exports = HomepageControl;
\ No newline at end of file
diff --git a/resources/assets/js/components/index.js b/resources/assets/js/components/index.js
index aa69f3265..768e0983f 100644
--- a/resources/assets/js/components/index.js
+++ b/resources/assets/js/components/index.js
@@ -18,6 +18,8 @@ let componentMapping = {
     'collapsible': require('./collapsible'),
     'toggle-switch': require('./toggle-switch'),
     'page-display': require('./page-display'),
+    'shelf-sort': require('./shelf-sort'),
+    'homepage-control': require('./homepage-control'),
 };
 
 window.components = {};
diff --git a/resources/assets/js/components/page-picker.js b/resources/assets/js/components/page-picker.js
index e697d5f68..5fd2920f4 100644
--- a/resources/assets/js/components/page-picker.js
+++ b/resources/assets/js/components/page-picker.js
@@ -15,18 +15,20 @@ class PagePicker {
     }
 
     setupListeners() {
-        // Select click
-        this.selectButton.addEventListener('click', event => {
-            window.EntitySelectorPopup.show(entity => {
-                this.setValue(entity.id, entity.name);
-            });
-        });
+        this.selectButton.addEventListener('click', this.showPopup.bind(this));
+        this.display.parentElement.addEventListener('click', this.showPopup.bind(this));
 
         this.resetButton.addEventListener('click', event => {
             this.setValue('', '');
         });
     }
 
+    showPopup() {
+        window.EntitySelectorPopup.show(entity => {
+            this.setValue(entity.id, entity.name);
+        });
+    }
+
     setValue(value, name) {
         this.value = value;
         this.input.value = value;
diff --git a/resources/assets/js/components/shelf-sort.js b/resources/assets/js/components/shelf-sort.js
new file mode 100644
index 000000000..59ac712a4
--- /dev/null
+++ b/resources/assets/js/components/shelf-sort.js
@@ -0,0 +1,71 @@
+
+class ShelfSort {
+
+    constructor(elem) {
+        this.elem = elem;
+        this.sortGroup = this.initSortable();
+        this.input = document.getElementById('books-input');
+        this.setupListeners();
+    }
+
+    initSortable() {
+        const sortable = require('jquery-sortable');
+        const placeHolderContent = this.getPlaceholderHTML();
+
+        return $('.scroll-box').sortable({
+            group: 'shelf-books',
+            exclude: '.instruction,.scroll-box-placeholder',
+            containerSelector: 'div.scroll-box',
+            itemSelector: '.scroll-box-item',
+            placeholder: placeHolderContent,
+            onDrop: this.onDrop.bind(this)
+        });
+    }
+
+    setupListeners() {
+        this.elem.addEventListener('click', event => {
+            const sortItem = event.target.closest('.scroll-box-item:not(.instruction)');
+            if (sortItem) {
+                event.preventDefault();
+                this.sortItemClick(sortItem);
+            }
+        });
+    }
+
+    /**
+     * Called when a sort item is clicked.
+     * @param {Element} sortItem
+     */
+    sortItemClick(sortItem) {
+        const lists = this.elem.querySelectorAll('.scroll-box');
+        const newList = Array.from(lists).filter(list => sortItem.parentElement !== list);
+        if (newList.length > 0) {
+            newList[0].appendChild(sortItem);
+        }
+        this.onChange();
+    }
+
+    onDrop($item, container, _super) {
+        this.onChange();
+        _super($item, container);
+    }
+
+    onChange() {
+        const data = this.sortGroup.sortable('serialize').get();
+        this.input.value = data[0].map(item => item.id).join(',');
+        const instruction = this.elem.querySelector('.scroll-box-item.instruction');
+        instruction.parentNode.insertBefore(instruction, instruction.parentNode.children[0]);
+    }
+
+    getPlaceholderHTML() {
+        const placeHolder = document.querySelector('.scroll-box-placeholder');
+        placeHolder.style.display = 'block';
+        const placeHolderContent = placeHolder.outerHTML;
+        placeHolder.style.display = 'none';
+        return placeHolderContent;
+    }
+
+
+}
+
+module.exports = ShelfSort;
\ No newline at end of file
diff --git a/resources/assets/sass/_grid.scss b/resources/assets/sass/_grid.scss
index 8f15153b5..0e1f85ce6 100644
--- a/resources/assets/sass/_grid.scss
+++ b/resources/assets/sass/_grid.scss
@@ -192,8 +192,26 @@ div[class^="col-"] img {
   flex-direction: column;
   border: 1px solid #ddd;
   min-width: 100px;
+  h2 {
+    width: 100%;
+    font-size: 1.5em;
+    margin: 0 0 10px;
+  }
+  h2 a {
+    display: block;
+    width: 100%;
+    line-height: 1.2;
+    text-decoration: none;
+  }
+  p {
+    font-size: .85em;
+    margin: 0;
+    line-height: 1.6em;
+  }
   .grid-card-content {
     flex: 1;
+    border-top: 0;
+    border-bottom-width: 2px;
   }
   .grid-card-content, .grid-card-footer {
     padding: $-l;
@@ -203,6 +221,23 @@ div[class^="col-"] img {
   }
 }
 
+.book-grid-item .grid-card-content h2 a  {
+    color: $color-book;
+    fill: $color-book;
+}
+
+.bookshelf-grid-item .grid-card-content h2 a  {
+  color: $color-bookshelf;
+  fill: $color-bookshelf;
+}
+
+.book-grid-item .grid-card-footer {
+  p.small {
+    font-size: .8em;
+    margin: 0;
+  }
+}
+
 @include smaller-than($m) {
   .grid.third {
     grid-template-columns: 1fr 1fr;
diff --git a/resources/assets/sass/_lists.scss b/resources/assets/sass/_lists.scss
index e8d131b52..0bf6be4c3 100644
--- a/resources/assets/sass/_lists.scss
+++ b/resources/assets/sass/_lists.scss
@@ -412,32 +412,3 @@ ul.pagination {
   }
 }
 
-.book-grid-item .grid-card-content {
-  border-top: 0;
-  border-bottom-width: 2px;
-  h2 {
-    width: 100%;
-    font-size: 1.5em;
-    margin: 0 0 10px;
-  }
-  h2 a {
-    display: block;
-    width: 100%;
-    line-height: 1.2;
-    color: #009688;;
-    fill: #009688;;
-    text-decoration: none;
-  }
-  p {
-    font-size: .85em;
-    margin: 0;
-    line-height: 1.6em;
-  }
-}
-
-.book-grid-item .grid-card-footer {
-  p.small {
-    font-size: .8em;
-    margin: 0;
-  }
-}
\ No newline at end of file
diff --git a/resources/assets/sass/_text.scss b/resources/assets/sass/_text.scss
index da11846d8..63a91c968 100644
--- a/resources/assets/sass/_text.scss
+++ b/resources/assets/sass/_text.scss
@@ -281,6 +281,14 @@ p.secondary, p .secondary, span.secondary, .text-secondary {
   }
 }
 
+.text-bookshelf {
+  color: $color-bookshelf;
+  fill: $color-bookshelf;
+  &:hover {
+    color: $color-bookshelf;
+    fill: $color-bookshelf;
+  }
+}
 .text-book {
   color: $color-book;
   fill: $color-book;
diff --git a/resources/assets/sass/_variables.scss b/resources/assets/sass/_variables.scss
index e62d37efe..006d1b3f0 100644
--- a/resources/assets/sass/_variables.scss
+++ b/resources/assets/sass/_variables.scss
@@ -47,6 +47,7 @@ $warning: $secondary;
 $primary-faded: rgba(21, 101, 192, 0.15);
 
 // Item Colors
+$color-bookshelf: #af5a5a;
 $color-book: #009688;
 $color-chapter: #ef7c3c;
 $color-page: $primary;
diff --git a/resources/assets/sass/styles.scss b/resources/assets/sass/styles.scss
index 0b2dfbf75..ab5972cbd 100644
--- a/resources/assets/sass/styles.scss
+++ b/resources/assets/sass/styles.scss
@@ -206,6 +206,12 @@ $btt-size: 40px;
     transition: all ease-in-out 120ms;
     cursor: pointer;
   }
+  &.compact {
+    font-size: 10px;
+    .entity-item-snippet {
+      display: none;
+    }
+  }
 }
 
 .entity-list-item.selected {
@@ -214,6 +220,20 @@ $btt-size: 40px;
   }
 }
 
+.scroll-box {
+  max-height: 250px;
+  overflow-y: scroll;
+  border: 1px solid #DDD;
+  border-radius: 3px;
+  .scroll-box-item {
+    padding: $-xs $-m;
+    border-bottom: 1px solid #DDD;
+    &:last-child {
+      border-bottom: 0;
+    }
+  }
+}
+
 .center-box {
   margin: $-xxl auto 0 auto;
   width: 420px;
diff --git a/resources/lang/en/activities.php b/resources/lang/en/activities.php
index 187fe1e53..153ae33f0 100644
--- a/resources/lang/en/activities.php
+++ b/resources/lang/en/activities.php
@@ -37,6 +37,14 @@ return [
     'book_sort'                   => 'sorted book',
     'book_sort_notification'      => 'Book Successfully Re-sorted',
 
+    // Bookshelves
+    'bookshelf_create'            => 'created Bookshelf',
+    'bookshelf_create_notification'    => 'Bookshelf Successfully Created',
+    'bookshelf_update'                 => 'updated bookshelf',
+    'bookshelf_update_notification'    => 'Bookshelf Successfully Updated',
+    'bookshelf_delete'                 => 'deleted bookshelf',
+    'bookshelf_delete_notification'    => 'Bookshelf Successfully Deleted',
+
     // Other
     'commented_on'                => 'commented on',
 ];
diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php
index c2744d906..8e86129e2 100644
--- a/resources/lang/en/common.php
+++ b/resources/lang/en/common.php
@@ -52,6 +52,7 @@ return [
     'details' => 'Details',
     'grid_view' => 'Grid View',
     'list_view' => 'List View',
+    'default' => 'Default',
 
     /**
      * Header
diff --git a/resources/lang/en/entities.php b/resources/lang/en/entities.php
index c99887401..4f110b724 100644
--- a/resources/lang/en/entities.php
+++ b/resources/lang/en/entities.php
@@ -64,6 +64,37 @@ return [
     'search_set_date' => 'Set Date',
     'search_update' => 'Update Search',
 
+    /**
+     * Shelves
+     */
+    'shelves' => 'Shelves',
+    'shelves_long' => 'Bookshelves',
+    'shelves_empty' => 'No shelves have been created',
+    'shelves_create' => 'Create New Shelf',
+    'shelves_popular' => 'Popular Shelves',
+    'shelves_new' => 'New Shelves',
+    'shelves_popular_empty' => 'The most popular shelves will appear here.',
+    'shelves_new_empty' => 'The most recently created shelves will appear here.',
+    'shelves_save' => 'Save Shelf',
+    'shelves_books' => 'Books on this shelf',
+    'shelves_add_books' => 'Add books to this shelf',
+    'shelves_drag_books' => 'Drag books here to add them to this shelf',
+    'shelves_empty_contents' => 'This shelf has no books assigned to it',
+    'shelves_edit_and_assign' => 'Edit shelf to assign books',
+    'shelves_edit_named' => 'Edit Bookshelf :name',
+    'shelves_edit' => 'Edit Bookshelf',
+    'shelves_delete' => 'Delete Bookshelf',
+    'shelves_delete_named' => 'Delete Bookshelf :name',
+    'shelves_delete_explain' => "This will delete the bookshelf with the name ':name'. Contained books will not be deleted.",
+    'shelves_delete_confirmation' => 'Are you sure you want to delete this bookshelf?',
+    'shelves_permissions' => 'Bookshelf Permissions',
+    'shelves_permissions_updated' => 'Bookshelf Permissions Updated',
+    'shelves_permissions_active' => 'Bookshelf Permissions Active',
+    'shelves_copy_permissions_to_books' => 'Copy Permissions to Books',
+    'shelves_copy_permissions' => 'Copy Permissions',
+    'shelves_copy_permissions_explain' => 'This will apply the current permission settings of this bookshelf to all books contained within. Before activating, ensure any changes to the permissions of this bookshelf have been saved.',
+    'shelves_copy_permission_success' => 'Bookshelf permissions copied to :count books',
+
     /**
      * Books
      */
@@ -199,6 +230,7 @@ return [
         'message' => ':start :time. Take care not to overwrite each other\'s updates!',
     ],
     'pages_draft_discarded' => 'Draft discarded, The editor has been updated with the current page content',
+    'pages_specific' => 'Specific Page',
 
     /**
      * Editor sidebar
@@ -206,6 +238,7 @@ return [
     'page_tags' => 'Page Tags',
     'chapter_tags' => 'Chapter Tags',
     'book_tags' => 'Book Tags',
+    'shelf_tags' => 'Shelf Tags',
     'tag' => 'Tag',
     'tags' =>  'Tags',
     'tag_value' => 'Tag Value (Optional)',
diff --git a/resources/lang/en/errors.php b/resources/lang/en/errors.php
index a86a1cdfc..fb09841cf 100644
--- a/resources/lang/en/errors.php
+++ b/resources/lang/en/errors.php
@@ -49,6 +49,7 @@ return [
 
     // Entities
     'entity_not_found' => 'Entity not found',
+    'bookshelf_not_found' => 'Bookshelf not found',
     'book_not_found' => 'Book not found',
     'page_not_found' => 'Page not found',
     'chapter_not_found' => 'Chapter not found',
diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php
index d6fbb6107..80ab77d19 100755
--- a/resources/lang/en/settings.php
+++ b/resources/lang/en/settings.php
@@ -32,9 +32,8 @@ return [
     'app_primary_color' => 'Application primary color',
     'app_primary_color_desc' => 'This should be a hex value. <br>Leave empty to reset to the default color.',
     'app_homepage' => 'Application Homepage',
-    'app_homepage_desc' => 'Select a page to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
-    'app_homepage_default' => 'Default homepage view chosen',
-    'app_homepage_books' => 'Or select the books page as your homepage. This will override any page selected as your homepage.',
+    'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
+    'app_homepage_select' => 'Select a page',
     'app_disable_comments' => 'Disable comments',
     'app_disable_comments_desc' => 'Disable comments across all pages in the application. Existing comments are not shown.',
 
@@ -91,6 +90,7 @@ return [
     'role_manage_settings' => 'Manage app settings',
     'role_asset' => 'Asset Permissions',
     'role_asset_desc' => 'These permissions control default access to the assets within the system. Permissions on Books, Chapters and Pages will override these permissions.',
+    'role_asset_admins' => 'Admins are automatically given access to all content but these options may show or hide UI options.',
     'role_all' => 'All',
     'role_own' => 'Own',
     'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to',
diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php
index 8f6c2eb46..016f8e833 100644
--- a/resources/views/base.blade.php
+++ b/resources/views/base.blade.php
@@ -33,7 +33,7 @@
     <header id="header">
         <div class="container fluid">
             <div class="row">
-                <div class="col-sm-4">
+                <div class="col-sm-4 col-md-3">
                     <a href="{{ baseUrl('/') }}" class="logo">
                         @if(setting('app-logo', '') !== 'none')
                             <img class="logo-image" src="{{ setting('app-logo', '') === '' ? baseUrl('/logo.png') : baseUrl(setting('app-logo', '')) }}" alt="Logo">
@@ -43,7 +43,7 @@
                         @endif
                     </a>
                 </div>
-                <div class="col-sm-8">
+                <div class="col-sm-8 col-md-9">
                     <div class="float right">
                         <div class="header-search">
                             <form action="{{ baseUrl('/search') }}" method="GET" class="search-box">
@@ -52,6 +52,9 @@
                             </form>
                         </div>
                         <div class="links text-center">
+                            @if(userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+                                <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
+                            @endif
                             <a href="{{ baseUrl('/books') }}">@icon('book'){{ trans('entities.books') }}</a>
                             @if(signedInUser() && userCan('settings-manage'))
                                 <a href="{{ baseUrl('/settings') }}">@icon('settings'){{ trans('settings.settings') }}</a>
diff --git a/resources/views/books/list.blade.php b/resources/views/books/list.blade.php
index 1c2056a79..9459cc008 100644
--- a/resources/views/books/list.blade.php
+++ b/resources/views/books/list.blade.php
@@ -1,9 +1,5 @@
 
-@if($booksViewType === 'list')
-    <div class="container small">
-@else
-    <div class="container">
-@endif
+<div class="container{{ $booksViewType === 'list' ? ' small' : '' }}">
     <h1>{{ trans('entities.books') }}</h1>
     @if(count($books) > 0)
         @if($booksViewType === 'list')
@@ -25,7 +21,7 @@
     @else
         <p class="text-muted">{{ trans('entities.books_empty') }}</p>
         @if(userCan('books-create-all'))
-            <a href="{{ baseUrl("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_one_now') }}</a>
+            <a href="{{ baseUrl("/create-book") }}" class="text-pos">@icon('edit'){{ trans('entities.create_now') }}</a>
         @endif
     @endif
 </div>
\ No newline at end of file
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php
index d0a2eb2f7..e5845b495 100644
--- a/resources/views/books/show.blade.php
+++ b/resources/views/books/show.blade.php
@@ -25,7 +25,7 @@
                     <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a>
                     <ul>
                         @if(userCan('book-update', $book))
-                            <li><a href="{{$book->getEditUrl()}}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li>
+                            <li><a href="{{ $book->getUrl('/edit') }}" class="text-primary">@icon('edit'){{ trans('common.edit') }}</a></li>
                             <li><a href="{{ $book->getUrl('/sort') }}" class="text-primary">@icon('sort'){{ trans('common.sort') }}</a></li>
                         @endif
                         @if(userCan('restrictions-manage', $book))
diff --git a/resources/views/books/view-toggle.blade.php b/resources/views/books/view-toggle.blade.php
index 61df7ab8d..63eb9b9d3 100644
--- a/resources/views/books/view-toggle.blade.php
+++ b/resources/views/books/view-toggle.blade.php
@@ -1,7 +1,7 @@
 <form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-book-view") }}" method="POST" class="inline">
     {!! csrf_field() !!}
     {!! method_field('PATCH') !!}
-    <input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="book_view_type">
+    <input type="hidden" value="{{ $booksViewType === 'list'? 'grid' : 'list' }}" name="view_type">
     @if ($booksViewType === 'list')
         <button type="submit" class="text-pos text-button">@icon('grid'){{ trans('common.grid_view') }}</button>
     @else
diff --git a/resources/views/common/home-shelves.blade.php b/resources/views/common/home-shelves.blade.php
new file mode 100644
index 000000000..3ae055b33
--- /dev/null
+++ b/resources/views/common/home-shelves.blade.php
@@ -0,0 +1,18 @@
+@extends('sidebar-layout')
+
+@section('toolbar')
+    <div class="col-sm-6 faded">
+        <div class="action-buttons text-left">
+            <a expand-toggle=".entity-list.compact .entity-item-snippet" class="text-primary text-button">@icon('expand-text'){{ trans('common.toggle_details') }}</a>
+            @include('shelves/view-toggle', ['shelvesViewType' => $shelvesViewType])
+        </div>
+    </div>
+@stop
+
+@section('sidebar')
+    @include('common/home-sidebar')
+@stop
+
+@section('body')
+    @include('shelves/list', ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType])
+@stop
\ No newline at end of file
diff --git a/resources/views/common/home.blade.php b/resources/views/common/home.blade.php
index bbddb072d..cc20fc68e 100644
--- a/resources/views/common/home.blade.php
+++ b/resources/views/common/home.blade.php
@@ -10,7 +10,7 @@
 
 @section('body')
 
-    <div class="container">
+    <div class="container" id="home-default">
         <div class="row">
 
             <div class="col-sm-4">
diff --git a/resources/views/partials/entity-list.blade.php b/resources/views/partials/entity-list.blade.php
index c90b953ea..371f38d71 100644
--- a/resources/views/partials/entity-list.blade.php
+++ b/resources/views/partials/entity-list.blade.php
@@ -8,6 +8,8 @@
                 @include('books/list-item', ['book' => $entity])
             @elseif($entity->isA('chapter'))
                 @include('chapters/list-item', ['chapter' => $entity, 'hidePages' => true])
+            @elseif($entity->isA('bookshelf'))
+                @include('shelves/list-item', ['bookshelf' => $entity])
             @endif
 
             @if($index !== count($entities) - 1)
diff --git a/resources/views/settings/index.blade.php b/resources/views/settings/index.blade.php
index 64017e6e0..3c563a61c 100644
--- a/resources/views/settings/index.blade.php
+++ b/resources/views/settings/index.blade.php
@@ -76,12 +76,22 @@
                             <input type="text" value="{{ setting('app-color') }}" name="setting-app-color" id="setting-app-color" placeholder="#0288D1">
                             <input type="hidden" value="{{ setting('app-color-light') }}" name="setting-app-color-light" id="setting-app-color-light">
                         </div>
-                        <div class="form-group" id="homepage-control">
+                        <div homepage-control class="form-group" id="homepage-control">
                             <label for="setting-app-homepage">{{ trans('settings.app_homepage') }}</label>
                             <p class="small">{{ trans('settings.app_homepage_desc') }}</p>
-                            @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_default'), 'value' => setting('app-homepage')])
-                            <p class="small">{{ trans('settings.app_homepage_books') }}</p>
-                            @include('components.toggle-switch', ['name' => 'setting-app-book-homepage', 'value' => setting('app-book-homepage')])
+
+                            <select name="setting-app-homepage-type" id="setting-app-homepage-type">
+                                <option @if(setting('app-homepage-type') === 'default') selected @endif value="default">{{ trans('common.default') }}</option>
+                                <option @if(setting('app-homepage-type') === 'books') selected @endif value="books">{{ trans('entities.books') }}</option>
+                                <option @if(setting('app-homepage-type') === 'bookshelves') selected @endif value="bookshelves">{{ trans('entities.shelves') }}</option>
+                                <option @if(setting('app-homepage-type') === 'page') selected @endif value="page">{{ trans('entities.pages_specific') }}</option>
+                            </select>
+
+                            <br><br>
+
+                            <div page-picker-container style="display: none;">
+                                @include('components.page-picker', ['name' => 'setting-app-homepage', 'placeholder' => trans('settings.app_homepage_select'), 'value' => setting('app-homepage')])
+                            </div>
                         </div>
                     </div>
 
diff --git a/resources/views/settings/roles/form.blade.php b/resources/views/settings/roles/form.blade.php
index 6a8e27487..619229a65 100644
--- a/resources/views/settings/roles/form.blade.php
+++ b/resources/views/settings/roles/form.blade.php
@@ -36,6 +36,10 @@
                     <h5>{{ trans('settings.role_asset') }}</h5>
                     <p>{{ trans('settings.role_asset_desc') }}</p>
 
+                    @if (isset($role) && $role->system_name === 'admin')
+                        <p>{{ trans('settings.role_asset_admins') }}</p>
+                    @endif
+
                     <table class="table">
                         <tr>
                             <th width="20%"></th>
@@ -44,6 +48,24 @@
                             <th width="20%">{{ trans('common.edit') }}</th>
                             <th width="20%">{{ trans('common.delete') }}</th>
                         </tr>
+                        <tr>
+                            <td>{{ trans('entities.shelves_long') }}</td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-create-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-view-own']) {{ trans('settings.role_own') }}</label>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-view-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-update-own']) {{ trans('settings.role_own') }}</label>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-update-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                            <td>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-delete-own']) {{ trans('settings.role_own') }}</label>
+                                <label>@include('settings/roles/checkbox', ['permission' => 'bookshelf-delete-all']) {{ trans('settings.role_all') }}</label>
+                            </td>
+                        </tr>
                         <tr>
                             <td>{{ trans('entities.books') }}</td>
                             <td>
diff --git a/resources/views/shelves/_breadcrumbs.blade.php b/resources/views/shelves/_breadcrumbs.blade.php
new file mode 100644
index 000000000..91b4252ef
--- /dev/null
+++ b/resources/views/shelves/_breadcrumbs.blade.php
@@ -0,0 +1,3 @@
+<div class="breadcrumbs">
+    <a href="{{$shelf->getUrl()}}" class="text-bookshelf text-button">@icon('bookshelf'){{ $shelf->getShortName() }}</a>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/create.blade.php b/resources/views/shelves/create.blade.php
new file mode 100644
index 000000000..32e40a4ae
--- /dev/null
+++ b/resources/views/shelves/create.blade.php
@@ -0,0 +1,31 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-8 faded">
+        <div class="breadcrumbs">
+            <a href="{{ baseUrl('/shelves') }}" class="text-button">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
+            <span class="sep">&raquo;</span>
+            <a href="{{ baseUrl('/create-shelf') }}" class="text-button">@icon('add'){{ trans('entities.shelves_create') }}</a>
+        </div>
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('add') {{ trans('entities.shelves_create') }}</h3>
+            <div class="body">
+                <form action="{{ baseUrl("/shelves") }}" method="POST" enctype="multipart/form-data">
+                    @include('shelves/form', ['shelf' => null, 'books' => $books])
+                </form>
+            </div>
+        </div>
+    </div>
+
+    <p class="margin-top large"><br></p>
+
+    @include('components.image-manager', ['imageType' => 'cover'])
+
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/delete.blade.php b/resources/views/shelves/delete.blade.php
new file mode 100644
index 000000000..f3ad62456
--- /dev/null
+++ b/resources/views/shelves/delete.blade.php
@@ -0,0 +1,30 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-12 faded">
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('delete') {{ trans('entities.shelves_delete') }}</h3>
+            <div class="body">
+                <p>{{ trans('entities.shelves_delete_explain', ['name' => $shelf->name]) }}</p>
+                <p class="text-neg">{{ trans('entities.shelves_delete_confirmation') }}</p>
+
+                <form action="{{ $shelf->getUrl() }}" method="POST">
+                    {!! csrf_field() !!}
+                    <input type="hidden" name="_method" value="DELETE">
+
+                    <a href="{{ $shelf->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button type="submit" class="button">{{ trans('common.confirm') }}</button>
+                </form>
+            </div>
+        </div>
+    </div>
+
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/edit.blade.php b/resources/views/shelves/edit.blade.php
new file mode 100644
index 000000000..ab88051e5
--- /dev/null
+++ b/resources/views/shelves/edit.blade.php
@@ -0,0 +1,24 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-12 faded">
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('edit') {{ trans('entities.shelves_edit') }}</h3>
+            <div class="body">
+                <form action="{{ $shelf->getUrl() }}" method="POST">
+                    <input type="hidden" name="_method" value="PUT">
+                    @include('shelves/form', ['model' => $shelf])
+                </form>
+            </div>
+        </div>
+    </div>
+@include('components.image-manager', ['imageType' => 'cover'])
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/export.blade.php b/resources/views/shelves/export.blade.php
new file mode 100644
index 000000000..462ad7991
--- /dev/null
+++ b/resources/views/shelves/export.blade.php
@@ -0,0 +1,80 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+    <title>{{ $book->name }}</title>
+
+    <style>
+        @if (!app()->environment('testing'))
+        {!! file_get_contents(public_path('/dist/export-styles.css')) !!}
+        @endif
+        .page-break {
+            page-break-after: always;
+        }
+        .chapter-hint {
+            color: #888;
+            margin-top: 32px;
+        }
+        .chapter-hint + h1 {
+            margin-top: 0;
+        }
+        ul.contents ul li {
+            list-style: circle;
+        }
+        @media screen {
+            .page-break {
+                border-top: 1px solid #DDD;
+            }
+        }
+    </style>
+    @yield('head')
+</head>
+<body>
+<div class="container">
+    <div class="row">
+        <div class="col-md-8 col-md-offset-2">
+            <div class="page-content">
+
+                <h1 style="font-size: 4.8em">{{$book->name}}</h1>
+
+                <p>{{ $book->description }}</p>
+
+                @if(count($bookChildren) > 0)
+                <ul class="contents">
+                    @foreach($bookChildren as $bookChild)
+                        <li><a href="#{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</a></li>
+                        @if($bookChild->isA('chapter') && count($bookChild->pages) > 0)
+                            <ul>
+                                @foreach($bookChild->pages as $page)
+                                    <li><a href="#page-{{$page->id}}">{{ $page->name }}</a></li>
+                                @endforeach
+                            </ul>
+                        @endif
+                    @endforeach
+                </ul>
+                @endif
+
+                @foreach($bookChildren as $bookChild)
+                    <div class="page-break"></div>
+                    <h1 id="{{$bookChild->getType()}}-{{$bookChild->id}}">{{ $bookChild->name }}</h1>
+                    @if($bookChild->isA('chapter'))
+                        <p>{{ $bookChild->description }}</p>
+                        @if(count($bookChild->pages) > 0)
+                            @foreach($bookChild->pages as $page)
+                                <div class="page-break"></div>
+                                <div class="chapter-hint">{{$bookChild->name}}</div>
+                                <h1 id="page-{{$page->id}}">{{ $page->name }}</h1>
+                                {!! $page->html !!}
+                            @endforeach
+                        @endif
+                    @else
+                        {!! $bookChild->html !!}
+                    @endif
+                @endforeach
+
+            </div>
+        </div>
+    </div>
+</div>
+</body>
+</html>
diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php
new file mode 100644
index 000000000..fb6fee115
--- /dev/null
+++ b/resources/views/shelves/form.blade.php
@@ -0,0 +1,84 @@
+
+{{ csrf_field() }}
+<div class="form-group title-input">
+    <label for="name">{{ trans('common.name') }}</label>
+    @include('form/text', ['name' => 'name'])
+</div>
+
+<div class="form-group description-input">
+    <label for="description">{{ trans('common.description') }}</label>
+    @include('form/textarea', ['name' => 'description'])
+</div>
+
+<div shelf-sort class="row">
+    <div class="col-md-6">
+        <div  class="form-group">
+            <label for="books">{{ trans('entities.shelves_books') }}</label>
+            <input type="hidden" id="books-input" name="books"
+                   value="{{ isset($shelf) ? $shelf->books->implode('id', ',') : '' }}">
+            <div class="scroll-box">
+                <div class="scroll-box-item text-small text-muted instruction">
+                    {{ trans('entities.shelves_drag_books') }}
+                </div>
+                <div class="scroll-box-item scroll-box-placeholder" style="display: none;">
+                    <a href="#" class="text-muted">@icon('book') ...</a>
+                </div>
+                @if (isset($shelfBooks) && count($shelfBooks) > 0)
+                    @foreach ($shelfBooks as $book)
+                        <div data-id="{{ $book->id }}" class="scroll-box-item">
+                            <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
+                        </div>
+                    @endforeach
+                @endif
+            </div>
+        </div>
+    </div>
+    <div class="col-md-6">
+        <div class="form-group">
+            <label for="books">{{ trans('entities.shelves_add_books') }}</label>
+            <div class="scroll-box">
+                @foreach ($books as $book)
+                    <div data-id="{{ $book->id }}" class="scroll-box-item">
+                        <a href="{{ $book->getUrl() }}" class="text-book">@icon('book'){{ $book->name }}</a>
+                    </div>
+                @endforeach
+            </div>
+        </div>
+    </div>
+</div>
+
+
+
+<div class="form-group" collapsible id="logo-control">
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="user-avatar">{{ trans('common.cover_image') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        <p class="small">{{ trans('common.cover_image_description') }}</p>
+
+        @include('components.image-picker', [
+            'resizeHeight' => '512',
+            'resizeWidth' => '512',
+            'showRemove' => false,
+            'defaultImage' => baseUrl('/book_default_cover.png'),
+            'currentImage' => isset($shelf) ? $shelf->getBookCover() : baseUrl('/book_default_cover.png') ,
+            'currentId' => isset($shelf) && $shelf->image_id ? $shelf->image_id : 0,
+            'name' => 'image_id',
+            'imageClass' => 'cover'
+        ])
+    </div>
+</div>
+
+<div class="form-group" collapsible id="tags-control">
+    <div class="collapse-title text-primary" collapsible-trigger>
+        <label for="tag-manager">{{ trans('entities.shelf_tags') }}</label>
+    </div>
+    <div class="collapse-content" collapsible-content>
+        @include('components.tag-manager', ['entity' => $shelf ?? null, 'entityType' => 'bookshelf'])
+    </div>
+</div>
+
+<div class="form-group text-right">
+    <a href="{{ isset($shelf) ? $shelf->getUrl() : baseUrl('/shelves') }}" class="button outline">{{ trans('common.cancel') }}</a>
+    <button type="submit" class="button pos">{{ trans('entities.shelves_save') }}</button>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/grid-item.blade.php b/resources/views/shelves/grid-item.blade.php
new file mode 100644
index 000000000..b70b5166e
--- /dev/null
+++ b/resources/views/shelves/grid-item.blade.php
@@ -0,0 +1,18 @@
+<div class="bookshelf-grid-item grid-card"  data-entity-type="bookshelf" data-entity-id="{{$bookshelf->id}}">
+    <div class="featured-image-container">
+        <a href="{{$bookshelf->getUrl()}}" title="{{$bookshelf->name}}">
+            <img src="{{$bookshelf->getBookCover()}}" alt="{{$bookshelf->name}}">
+        </a>
+    </div>
+    <div class="grid-card-content">
+        <h2><a class="break-text" href="{{$bookshelf->getUrl()}}" title="{{$bookshelf->name}}">{{$bookshelf->getShortName(35)}}</a></h2>
+        @if(isset($bookshelf->searchSnippet))
+            <p >{!! $bookshelf->searchSnippet !!}</p>
+        @else
+            <p >{{ $bookshelf->getExcerpt(130) }}</p>
+        @endif
+    </div>
+    <div class="grid-card-footer text-muted text-small">
+        <span>@include('partials.entity-meta', ['entity' => $bookshelf])</span>
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/index.blade.php b/resources/views/shelves/index.blade.php
new file mode 100644
index 000000000..a887a843e
--- /dev/null
+++ b/resources/views/shelves/index.blade.php
@@ -0,0 +1,48 @@
+@extends('sidebar-layout')
+
+@section('toolbar')
+    <div class="col-xs-6 faded">
+        <div class="action-buttons text-left">
+            @include('shelves/view-toggle', ['shelvesViewType' => $shelvesViewType])
+        </div>
+    </div>
+    <div class="col-xs-6 faded">
+        <div class="action-buttons">
+            @if($currentUser->can('bookshelf-create-all'))
+                <a href="{{ baseUrl("/create-shelf") }}" class="text-pos text-button">@icon('add'){{ trans('entities.shelves_create') }}</a>
+            @endif
+        </div>
+    </div>
+@stop
+
+@section('sidebar')
+    @if($recents)
+        <div id="recents" class="card">
+            <h3>@icon('view') {{ trans('entities.recently_viewed') }}</h3>
+            @include('partials/entity-list', ['entities' => $recents, 'style' => 'compact'])
+        </div>
+    @endif
+
+    <div id="popular" class="card">
+        <h3>@icon('popular') {{ trans('entities.shelves_popular') }}</h3>
+        @if(count($popular) > 0)
+            @include('partials/entity-list', ['entities' => $popular, 'style' => 'compact'])
+        @else
+            <div class="body text-muted">{{ trans('entities.shelves_popular_empty') }}</div>
+        @endif
+    </div>
+
+    <div id="new" class="card">
+        <h3>@icon('star-circle') {{ trans('entities.shelves_new') }}</h3>
+        @if(count($new) > 0)
+            @include('partials/entity-list', ['entities' => $new, 'style' => 'compact'])
+        @else
+            <div class="body text-muted">{{ trans('entities.shelves_new_empty') }}</div>
+        @endif
+    </div>
+@stop
+
+@section('body')
+    @include('shelves/list', ['shelves' => $shelves, 'shelvesViewType' => $shelvesViewType])
+    <p><br></p>
+@stop
\ No newline at end of file
diff --git a/resources/views/shelves/list-item.blade.php b/resources/views/shelves/list-item.blade.php
new file mode 100644
index 000000000..0b8e79fe5
--- /dev/null
+++ b/resources/views/shelves/list-item.blade.php
@@ -0,0 +1,10 @@
+<div class="shelf entity-list-item"  data-entity-type="bookshelf" data-entity-id="{{$bookshelf->id}}">
+    <h4 class="text-shelf"><a class="text-bookshelf entity-list-item-link" href="{{$bookshelf->getUrl()}}">@icon('bookshelf')<span class="entity-list-item-name break-text">{{$bookshelf->name}}</span></a></h4>
+    <div class="entity-item-snippet">
+        @if(isset($bookshelf->searchSnippet))
+            <p class="text-muted break-text">{!! $bookshelf->searchSnippet !!}</p>
+        @else
+            <p class="text-muted break-text">{{ $bookshelf->getExcerpt() }}</p>
+        @endif
+    </div>
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/list.blade.php b/resources/views/shelves/list.blade.php
new file mode 100644
index 000000000..ff11d2d67
--- /dev/null
+++ b/resources/views/shelves/list.blade.php
@@ -0,0 +1,26 @@
+
+<div class="container{{ $shelvesViewType === 'list' ? ' small' : '' }}">
+    <h1>{{ trans('entities.shelves') }}</h1>
+    @if(count($shelves) > 0)
+        @if($shelvesViewType === 'grid')
+            <div class="grid third">
+                @foreach($shelves as $key => $shelf)
+                    @include('shelves/grid-item', ['bookshelf' => $shelf])
+                @endforeach
+            </div>
+        @else
+            @foreach($shelves as $shelf)
+                @include('shelves/list-item', ['bookshelf' => $shelf])
+                <hr>
+            @endforeach
+        @endif
+        <div>
+            {!! $shelves->render() !!}
+        </div>
+    @else
+        <p class="text-muted">{{ trans('entities.shelves_empty') }}</p>
+        @if(userCan('bookshelf-create-all'))
+            <a href="{{ baseUrl("/create-shelf") }}" class="button outline">@icon('edit'){{ trans('entities.create_now') }}</a>
+        @endif
+    @endif
+</div>
\ No newline at end of file
diff --git a/resources/views/shelves/restrictions.blade.php b/resources/views/shelves/restrictions.blade.php
new file mode 100644
index 000000000..472078ad2
--- /dev/null
+++ b/resources/views/shelves/restrictions.blade.php
@@ -0,0 +1,34 @@
+@extends('simple-layout')
+
+@section('toolbar')
+    <div class="col-sm-12 faded">
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
+    </div>
+@stop
+
+@section('body')
+
+    <div class="container small">
+        <p>&nbsp;</p>
+        <div class="card">
+            <h3>@icon('lock') {{ trans('entities.shelves_permissions') }}</h3>
+            <div class="body">
+                @include('form/restriction-form', ['model' => $shelf])
+            </div>
+        </div>
+
+        <p>&nbsp;</p>
+
+        <div class="card">
+            <h3>@icon('copy') {{ trans('entities.shelves_copy_permissions_to_books') }}</h3>
+            <div class="body">
+                <p>{{ trans('entities.shelves_copy_permissions_explain') }}</p>
+                <form action="{{ $shelf->getUrl('/copy-permissions') }}" method="post" class="text-right">
+                    {{ csrf_field() }}
+                    <button class="button">{{ trans('entities.shelves_copy_permissions') }}</button>
+                </form>
+            </div>
+        </div>
+    </div>
+
+@stop
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php
new file mode 100644
index 000000000..2aae2c6ff
--- /dev/null
+++ b/resources/views/shelves/show.blade.php
@@ -0,0 +1,88 @@
+@extends('sidebar-layout')
+
+@section('toolbar')
+    <div class="col-sm-6 col-xs-1  faded">
+        @include('shelves._breadcrumbs', ['shelf' => $shelf])
+    </div>
+    <div class="col-sm-6 col-xs-11">
+        <div class="action-buttons faded">
+            @if(userCan('bookshelf-update', $shelf))
+                <a href="{{ $shelf->getUrl('/edit') }}" class="text-button text-primary">@icon('edit'){{ trans('common.edit') }}</a>
+            @endif
+            @if(userCan('restrictions-manage', $shelf) || userCan('bookshelf-delete', $shelf))
+                <div dropdown class="dropdown-container">
+                    <a dropdown-toggle class="text-primary text-button">@icon('more'){{ trans('common.more') }}</a>
+                    <ul>
+                        @if(userCan('restrictions-manage', $shelf))
+                            <li><a href="{{ $shelf->getUrl('/permissions') }}" class="text-primary">@icon('lock'){{ trans('entities.permissions') }}</a></li>
+                        @endif
+                        @if(userCan('bookshelf-delete', $shelf))
+                            <li><a href="{{ $shelf->getUrl('/delete') }}" class="text-neg">@icon('delete'){{ trans('common.delete') }}</a></li>
+                        @endif
+                    </ul>
+                </div>
+            @endif
+        </div>
+    </div>
+@stop
+
+@section('sidebar')
+
+    @if($shelf->tags->count() > 0)
+        <section>
+            @include('components.tag-list', ['entity' => $shelf])
+        </section>
+    @endif
+
+    <div class="card entity-details">
+        <h3>@icon('info') {{ trans('common.details') }}</h3>
+        <div class="body text-small text-muted blended-links">
+            @include('partials.entity-meta', ['entity' => $shelf])
+            @if($shelf->restricted)
+                <div class="active-restriction">
+                    @if(userCan('restrictions-manage', $shelf))
+                        <a href="{{ $shelf->getUrl('/permissions') }}">@icon('lock'){{ trans('entities.shelves_permissions_active') }}</a>
+                    @else
+                        @icon('lock'){{ trans('entities.shelves_permissions_active') }}
+                    @endif
+                </div>
+            @endif
+        </div>
+    </div>
+
+    @if(count($activity) > 0)
+        <div class="activity card">
+            <h3>@icon('time') {{ trans('entities.recent_activity') }}</h3>
+            @include('partials/activity-list', ['activity' => $activity])
+        </div>
+    @endif
+@stop
+
+@section('body')
+
+    <div class="container small nopad">
+        <h1 class="break-text">{{$shelf->name}}</h1>
+        <div class="book-content">
+            <p class="text-muted">{!! nl2br(e($shelf->description)) !!}</p>
+            @if(count($books) > 0)
+            <div class="page-list">
+                <hr>
+                @foreach($books as $book)
+                    @include('books/list-item', ['book' => $book])
+                    <hr>
+                @endforeach
+            </div>
+            @else
+            <p>
+                <hr>
+                <span class="text-muted italic">{{ trans('entities.shelves_empty_contents') }}</span>
+                @if(userCan('bookshelf-create', $shelf))
+                    <br>
+                    <a href="{{ $shelf->getUrl('/edit') }}" class="button outline bookshelf">{{ trans('entities.shelves_edit_and_assign') }}</a>
+                @endif
+            </p>
+            @endif
+
+    </div>
+
+@stop
diff --git a/resources/views/shelves/view-toggle.blade.php b/resources/views/shelves/view-toggle.blade.php
new file mode 100644
index 000000000..785e8cac1
--- /dev/null
+++ b/resources/views/shelves/view-toggle.blade.php
@@ -0,0 +1,10 @@
+<form action="{{ baseUrl("/settings/users/{$currentUser->id}/switch-shelf-view") }}" method="POST" class="inline">
+    {!! csrf_field() !!}
+    {!! method_field('PATCH') !!}
+    <input type="hidden" value="{{ $shelvesViewType === 'list'? 'grid' : 'list' }}" name="view_type">
+    @if ($shelvesViewType === 'list')
+        <button type="submit" class="text-pos text-button">@icon('grid'){{ trans('common.grid_view') }}</button>
+    @else
+        <button type="submit" class="text-pos text-button">@icon('list'){{ trans('common.list_view') }}</button>
+    @endif
+</form>
\ No newline at end of file
diff --git a/routes/web.php b/routes/web.php
index 70cb3c130..d3c5f46d3 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -14,6 +14,21 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/recently-updated', 'PageController@showRecentlyUpdated');
     });
 
+    // Shelves
+    Route::get('/create-shelf', 'BookshelfController@create');
+    Route::group(['prefix' => 'shelves'], function() {
+        Route::get('/', 'BookshelfController@index');
+        Route::post('/', 'BookshelfController@store');
+        Route::get('/{slug}/edit', 'BookshelfController@edit');
+        Route::get('/{slug}/delete', 'BookshelfController@showDelete');
+        Route::get('/{slug}', 'BookshelfController@show');
+        Route::put('/{slug}', 'BookshelfController@update');
+        Route::delete('/{slug}', 'BookshelfController@destroy');
+        Route::get('/{slug}/permissions', 'BookshelfController@showRestrict');
+        Route::put('/{slug}/permissions', 'BookshelfController@restrict');
+        Route::post('/{slug}/copy-permissions', 'BookshelfController@copyPermissions');
+    });
+
     Route::get('/create-book', 'BookController@create');
     Route::group(['prefix' => 'books'], function () {
 
@@ -160,6 +175,7 @@ Route::group(['middleware' => 'auth'], function () {
         Route::get('/users/create', 'UserController@create');
         Route::get('/users/{id}/delete', 'UserController@delete');
         Route::patch('/users/{id}/switch-book-view', 'UserController@switchBookView');
+        Route::patch('/users/{id}/switch-shelf-view', 'UserController@switchShelfView');
         Route::post('/users/create', 'UserController@store');
         Route::get('/users/{id}', 'UserController@edit');
         Route::put('/users/{id}', 'UserController@update');
diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php
new file mode 100644
index 000000000..9071e3c06
--- /dev/null
+++ b/tests/Entity/BookShelfTest.php
@@ -0,0 +1,170 @@
+<?php namespace Tests;
+
+use BookStack\Book;
+use BookStack\Bookshelf;
+
+class BookShelfTest extends TestCase
+{
+
+    public function test_shelves_shows_in_header_if_have_view_permissions()
+    {
+        $viewer = $this->getViewer();
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+
+        $viewer->roles()->delete();
+        $this->giveUserPermissions($viewer);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementNotContains('header', 'Shelves');
+
+        $this->giveUserPermissions($viewer, ['bookshelf-view-all']);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+
+        $viewer->roles()->delete();
+        $this->giveUserPermissions($viewer, ['bookshelf-view-own']);
+        $resp = $this->actingAs($viewer)->get('/');
+        $resp->assertElementContains('header', 'Shelves');
+    }
+
+    public function test_shelves_page_contains_create_link()
+    {
+        $resp = $this->asEditor()->get('/shelves');
+        $resp->assertElementContains('a', 'Create New Shelf');
+    }
+
+    public function test_shelves_create()
+    {
+        $booksToInclude = Book::take(2)->get();
+        $shelfInfo = [
+            'name' => 'My test book' . str_random(4),
+            'description' => 'Test book description ' . str_random(10)
+        ];
+        $resp = $this->asEditor()->post('/shelves', array_merge($shelfInfo, [
+            'books' => $booksToInclude->implode('id', ','),
+            'tags' => [
+                [
+                    'name' => 'Test Category',
+                    'value' => 'Test Tag Value',
+                ]
+            ],
+        ]));
+        $resp->assertRedirect();
+        $editorId = $this->getEditor()->id;
+        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId]));
+
+        $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first();
+        $shelfPage = $this->get($shelf->getUrl());
+        $shelfPage->assertSee($shelfInfo['name']);
+        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertElementContains('.tag-item', 'Test Category');
+        $shelfPage->assertElementContains('.tag-item', 'Test Tag Value');
+
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
+    }
+
+    public function test_shelf_view()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertStatus(200);
+        $resp->assertSeeText($shelf->name);
+        $resp->assertSeeText($shelf->description);
+
+        foreach ($shelf->books as $book) {
+            $resp->assertSee($book->name);
+        }
+    }
+
+    public function test_shelf_view_shows_action_buttons()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl());
+        $resp->assertSee($shelf->getUrl('/edit'));
+        $resp->assertSee($shelf->getUrl('/permissions'));
+        $resp->assertSee($shelf->getUrl('/delete'));
+        $resp->assertElementContains('a', 'Edit');
+        $resp->assertElementContains('a', 'Permissions');
+        $resp->assertElementContains('a', 'Delete');
+
+        $resp = $this->asEditor()->get($shelf->getUrl());
+        $resp->assertDontSee($shelf->getUrl('/permissions'));
+    }
+
+    public function test_shelf_edit()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/edit'));
+        $resp->assertSeeText('Edit Bookshelf');
+
+        $booksToInclude = Book::take(2)->get();
+        $shelfInfo = [
+            'name' => 'My test book' . str_random(4),
+            'description' => 'Test book description ' . str_random(10)
+        ];
+
+        $resp = $this->asEditor()->put($shelf->getUrl(), array_merge($shelfInfo, [
+            'books' => $booksToInclude->implode('id', ','),
+            'tags' => [
+                [
+                    'name' => 'Test Category',
+                    'value' => 'Test Tag Value',
+                ]
+            ],
+        ]));
+        $shelf = Bookshelf::find($shelf->id);
+        $resp->assertRedirect($shelf->getUrl());
+        $this->assertSessionHas('success');
+
+        $editorId = $this->getEditor()->id;
+        $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId]));
+
+        $shelfPage = $this->get($shelf->getUrl());
+        $shelfPage->assertSee($shelfInfo['name']);
+        $shelfPage->assertSee($shelfInfo['description']);
+        $shelfPage->assertElementContains('.tag-item', 'Test Category');
+        $shelfPage->assertElementContains('.tag-item', 'Test Tag Value');
+
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[0]->id]);
+        $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id, 'book_id' => $booksToInclude[1]->id]);
+    }
+
+    public function test_shelf_delete()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asEditor()->get($shelf->getUrl('/delete'));
+        $resp->assertSeeText('Delete Bookshelf');
+        $resp->assertSee("action=\"{$shelf->getUrl()}\"");
+
+        $resp = $this->delete($shelf->getUrl());
+        $resp->assertRedirect('/shelves');
+        $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]);
+        $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]);
+        $this->assertSessionHas('success');
+    }
+
+    public function test_shelf_copy_permissions()
+    {
+        $shelf = Bookshelf::first();
+        $resp = $this->asAdmin()->get($shelf->getUrl('/permissions'));
+        $resp->assertSeeText('Copy Permissions');
+        $resp->assertSee("action=\"{$shelf->getUrl('/copy-permissions')}\"");
+
+        $child = $shelf->books()->first();
+        $editorRole = $this->getEditor()->roles()->first();
+        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
+        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+
+        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
+        $resp = $this->post($shelf->getUrl('/copy-permissions'));
+        $child = $shelf->books()->first();
+
+        $resp->assertRedirect($shelf->getUrl());
+        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
+        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+    }
+
+}
diff --git a/tests/ErrorTest.php b/tests/ErrorTest.php
index c9b5a0109..a5e4a4a5e 100644
--- a/tests/ErrorTest.php
+++ b/tests/ErrorTest.php
@@ -9,10 +9,13 @@ class ErrorTest extends TestCase
         // if our custom, middleware-loaded handler fails but this is here
         // as a reminder and as a general check in the event of other issues.
         $editor = $this->getEditor();
+        $editor->name = 'tester';
+        $editor->save();
+
         $this->actingAs($editor);
         $notFound = $this->get('/fgfdngldfnotfound');
         $notFound->assertStatus(404);
         $notFound->assertDontSeeText('Log in');
-        $notFound->assertSeeText($editor->getShortName(9));
+        $notFound->assertSeeText('tester');
     }
 }
\ No newline at end of file
diff --git a/tests/HomepageTest.php b/tests/HomepageTest.php
index 29e0985c3..86cae7893 100644
--- a/tests/HomepageTest.php
+++ b/tests/HomepageTest.php
@@ -10,15 +10,17 @@ class HomepageTest extends TestCase
         $homeVisit->assertSee('My Recently Viewed');
         $homeVisit->assertSee('Recently Updated Pages');
         $homeVisit->assertSee('Recent Activity');
+        $homeVisit->assertSee('home-default');
     }
 
     public function test_custom_homepage()
     {
         $this->asEditor();
         $name = 'My custom homepage';
-        $content = 'This is the body content of my custom homepage.';
+        $content = str_repeat('This is the body content of my custom homepage.', 20);
         $customPage = $this->newPage(['name' => $name, 'html' => $content]);
         $this->setSettings(['app-homepage' => $customPage->id]);
+        $this->setSettings(['app-homepage-type' => 'page']);
 
         $homeVisit = $this->get('/');
         $homeVisit->assertSee($name);
@@ -32,7 +34,7 @@ class HomepageTest extends TestCase
     {
         $this->asEditor();
         $name = 'My custom homepage';
-        $content = 'This is the body content of my custom homepage.';
+        $content = str_repeat('This is the body content of my custom homepage.', 20);
         $customPage = $this->newPage(['name' => $name, 'html' => $content]);
         $this->setSettings(['app-homepage' => $customPage->id]);
 
@@ -55,7 +57,7 @@ class HomepageTest extends TestCase
         $editor = $this->getEditor();
         setting()->putUser($editor, 'books_view_type', 'grid');
 
-        $this->setSettings(['app-book-homepage' => true]);
+        $this->setSettings(['app-homepage-type' => 'books']);
 
         $this->asEditor();
         $homeVisit = $this->get('/');
@@ -65,7 +67,26 @@ class HomepageTest extends TestCase
         $homeVisit->assertSee('grid-card-footer');
         $homeVisit->assertSee('featured-image-container');
 
-        $this->setSettings(['app-book-homepage' => false]);
+        $this->setSettings(['app-homepage-type' => false]);
+        $this->test_default_homepage_visible();
+    }
+
+    public function test_set_bookshelves_homepage()
+    {
+        $editor = $this->getEditor();
+        setting()->putUser($editor, 'bookshelves_view_type', 'grid');
+
+        $this->setSettings(['app-homepage-type' => 'bookshelves']);
+
+        $this->asEditor();
+        $homeVisit = $this->get('/');
+        $homeVisit->assertSee('Shelves');
+        $homeVisit->assertSee('bookshelf-grid-item grid-card');
+        $homeVisit->assertSee('grid-card-content');
+        $homeVisit->assertSee('grid-card-footer');
+        $homeVisit->assertSee('featured-image-container');
+
+        $this->setSettings(['app-homepage-type' => false]);
         $this->test_default_homepage_visible();
     }
 }
diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php
index 2bbb1a5fa..540125fd1 100644
--- a/tests/Permissions/RestrictionsTest.php
+++ b/tests/Permissions/RestrictionsTest.php
@@ -1,6 +1,7 @@
 <?php namespace Tests;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Entity;
 use BookStack\User;
 use BookStack\Repos\EntityRepo;
@@ -34,6 +35,63 @@ class RestrictionsTest extends BrowserKitTest
         parent::setEntityRestrictions($entity, $actions, $roles);
     }
 
+    public function test_bookshelf_view_restriction()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl())
+            ->seePageIs($shelf->getUrl());
+
+        $this->setEntityRestrictions($shelf, []);
+
+        $this->forceVisit($shelf->getUrl())
+            ->see('Bookshelf not found');
+
+        $this->setEntityRestrictions($shelf, ['view']);
+
+        $this->visit($shelf->getUrl())
+            ->see($shelf->name);
+    }
+
+    public function test_bookshelf_update_restriction()
+    {
+        $shelf = BookShelf::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl('/edit'))
+            ->see('Edit Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->forceVisit($shelf->getUrl('/edit'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->visit($shelf->getUrl('/edit'))
+            ->seePageIs($shelf->getUrl('/edit'));
+    }
+
+    public function test_bookshelf_delete_restriction()
+    {
+        $shelf = Book::first();
+
+        $this->actingAs($this->user)
+            ->visit($shelf->getUrl('/delete'))
+            ->see('Delete Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->forceVisit($shelf->getUrl('/delete'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->visit($shelf->getUrl('/delete'))
+            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+    }
+
     public function test_book_view_restriction()
     {
         $book = Book::first();
@@ -325,6 +383,23 @@ class RestrictionsTest extends BrowserKitTest
             ->seePageIs($pageUrl . '/delete')->see('Delete Page');
     }
 
+    public function test_bookshelf_restriction_form()
+    {
+        $shelf = Bookshelf::first();
+        $this->asAdmin()->visit($shelf->getUrl('/permissions'))
+            ->see('Bookshelf Permissions')
+            ->check('restricted')
+            ->check('restrictions[2][view]')
+            ->press('Save Permissions')
+            ->seeInDatabase('bookshelves', ['id' => $shelf->id, 'restricted' => true])
+            ->seeInDatabase('entity_permissions', [
+                'restrictable_id' => $shelf->id,
+                'restrictable_type' => 'BookStack\Bookshelf',
+                'role_id' => '2',
+                'action' => 'view'
+            ]);
+    }
+
     public function test_book_restriction_form()
     {
         $book = Book::first();
@@ -413,6 +488,44 @@ class RestrictionsTest extends BrowserKitTest
             ->dontSee($page->name);
     }
 
+    public function test_bookshelf_update_restriction_override()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->viewer)
+            ->visit($shelf->getUrl('/edit'))
+            ->dontSee('Edit Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->forceVisit($shelf->getUrl('/edit'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->visit($shelf->getUrl('/edit'))
+            ->seePageIs($shelf->getUrl('/edit'));
+    }
+
+    public function test_bookshelf_delete_restriction_override()
+    {
+        $shelf = Bookshelf::first();
+
+        $this->actingAs($this->viewer)
+            ->visit($shelf->getUrl('/delete'))
+            ->dontSee('Delete Book');
+
+        $this->setEntityRestrictions($shelf, ['view', 'update']);
+
+        $this->forceVisit($shelf->getUrl('/delete'))
+            ->see('You do not have permission')->seePageIs('/');
+
+        $this->setEntityRestrictions($shelf, ['view', 'delete']);
+
+        $this->visit($shelf->getUrl('/delete'))
+            ->seePageIs($shelf->getUrl('/delete'))->see('Delete Book');
+    }
+
     public function test_book_create_restriction_override()
     {
         $book = Book::first();
diff --git a/tests/Permissions/RolesTest.php b/tests/Permissions/RolesTest.php
index f076e6734..e0f827d02 100644
--- a/tests/Permissions/RolesTest.php
+++ b/tests/Permissions/RolesTest.php
@@ -1,5 +1,6 @@
 <?php namespace Tests;
 
+use BookStack\Bookshelf;
 use BookStack\Page;
 use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
@@ -16,32 +17,6 @@ class RolesTest extends BrowserKitTest
         $this->user = $this->getViewer();
     }
 
-    /**
-     * Give the given user some permissions.
-     * @param \BookStack\User $user
-     * @param array $permissions
-     */
-    protected function giveUserPermissions(\BookStack\User $user, $permissions = [])
-    {
-        $newRole = $this->createNewRole($permissions);
-        $user->attachRole($newRole);
-        $user->load('roles');
-        $user->permissions(false);
-    }
-
-    /**
-     * Create a new basic role for testing purposes.
-     * @param array $permissions
-     * @return Role
-     */
-    protected function createNewRole($permissions = [])
-    {
-        $permissionRepo = app(PermissionsRepo::class);
-        $roleData = factory(\BookStack\Role::class)->make()->toArray();
-        $roleData['permissions'] = array_flip($permissions);
-        return $permissionRepo->saveNewRole($roleData);
-    }
-
     public function test_admin_can_see_settings()
     {
         $this->asAdmin()->visit('/settings')->see('Settings');
@@ -203,6 +178,90 @@ class RolesTest extends BrowserKitTest
         }
     }
 
+    public function test_bookshelves_create_all_permissions()
+    {
+        $this->checkAccessPermission('bookshelf-create-all', [
+            '/create-shelf'
+        ], [
+            '/shelves' => 'Create New Shelf'
+        ]);
+
+        $this->visit('/create-shelf')
+            ->type('test shelf', 'name')
+            ->type('shelf desc', 'description')
+            ->press('Save Shelf')
+            ->seePageIs('/shelves/test-shelf');
+    }
+
+    public function test_bookshelves_edit_own_permission()
+    {
+        $otherShelf = Bookshelf::first();
+        $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
+        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $this->regenEntityPermissions($ownShelf);
+
+        $this->checkAccessPermission('bookshelf-update-own', [
+            $ownShelf->getUrl('/edit')
+        ], [
+            $ownShelf->getUrl() => 'Edit'
+        ]);
+
+        $this->visit($otherShelf->getUrl())
+            ->dontSeeInElement('.action-buttons', 'Edit')
+            ->visit($otherShelf->getUrl('/edit'))
+            ->seePageIs('/');
+    }
+
+    public function test_bookshelves_edit_all_permission()
+    {
+        $otherShelf = \BookStack\Bookshelf::first();
+        $this->checkAccessPermission('bookshelf-update-all', [
+            $otherShelf->getUrl('/edit')
+        ], [
+            $otherShelf->getUrl() => 'Edit'
+        ]);
+    }
+
+    public function test_bookshelves_delete_own_permission()
+    {
+        $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
+        $otherShelf = \BookStack\Bookshelf::first();
+        $ownShelf = $this->newShelf(['name' => 'test-shelf', 'slug' => 'test-shelf']);
+        $ownShelf->forceFill(['created_by' => $this->user->id, 'updated_by' => $this->user->id])->save();
+        $this->regenEntityPermissions($ownShelf);
+
+        $this->checkAccessPermission('bookshelf-delete-own', [
+            $ownShelf->getUrl('/delete')
+        ], [
+            $ownShelf->getUrl() => 'Delete'
+        ]);
+
+        $this->visit($otherShelf->getUrl())
+            ->dontSeeInElement('.action-buttons', 'Delete')
+            ->visit($otherShelf->getUrl('/delete'))
+            ->seePageIs('/');
+        $this->visit($ownShelf->getUrl())->visit($ownShelf->getUrl('/delete'))
+            ->press('Confirm')
+            ->seePageIs('/shelves')
+            ->dontSee($ownShelf->name);
+    }
+
+    public function test_bookshelves_delete_all_permission()
+    {
+        $this->giveUserPermissions($this->user, ['bookshelf-update-all']);
+        $otherShelf = \BookStack\Bookshelf::first();
+        $this->checkAccessPermission('bookshelf-delete-all', [
+            $otherShelf->getUrl('/delete')
+        ], [
+            $otherShelf->getUrl() => 'Delete'
+        ]);
+
+        $this->visit($otherShelf->getUrl())->visit($otherShelf->getUrl('/delete'))
+            ->press('Confirm')
+            ->seePageIs('/shelves')
+            ->dontSee($otherShelf->name);
+    }
+
     public function test_books_create_all_permissions()
     {
         $this->checkAccessPermission('book-create-all', [
diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php
index 325979e74..581dac5f1 100644
--- a/tests/SharedTestHelpers.php
+++ b/tests/SharedTestHelpers.php
@@ -1,9 +1,11 @@
 <?php namespace Tests;
 
 use BookStack\Book;
+use BookStack\Bookshelf;
 use BookStack\Chapter;
 use BookStack\Entity;
 use BookStack\Repos\EntityRepo;
+use BookStack\Repos\PermissionsRepo;
 use BookStack\Role;
 use BookStack\Services\PermissionService;
 use BookStack\Services\SettingService;
@@ -69,6 +71,25 @@ trait SharedTestHelpers
         return $user;
     }
 
+    /**
+     * Regenerate the permission for an entity.
+     * @param Entity $entity
+     */
+    protected function regenEntityPermissions(Entity $entity)
+    {
+        $this->app[PermissionService::class]->buildJointPermissionsForEntity($entity);
+        $entity->load('jointPermissions');
+    }
+
+    /**
+     * Create and return a new bookshelf.
+     * @param array $input
+     * @return Bookshelf
+     */
+    public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) {
+        return $this->app[EntityRepo::class]->createFromInput('bookshelf', $input, false);
+    }
+
     /**
      * Create and return a new book.
      * @param array $input
@@ -140,4 +161,30 @@ trait SharedTestHelpers
         $entity->load('jointPermissions');
     }
 
+    /**
+     * Give the given user some permissions.
+     * @param \BookStack\User $user
+     * @param array $permissions
+     */
+    protected function giveUserPermissions(\BookStack\User $user, $permissions = [])
+    {
+        $newRole = $this->createNewRole($permissions);
+        $user->attachRole($newRole);
+        $user->load('roles');
+        $user->permissions(false);
+    }
+
+    /**
+     * Create a new basic role for testing purposes.
+     * @param array $permissions
+     * @return Role
+     */
+    protected function createNewRole($permissions = [])
+    {
+        $permissionRepo = app(PermissionsRepo::class);
+        $roleData = factory(Role::class)->make()->toArray();
+        $roleData['permissions'] = array_flip($permissions);
+        return $permissionRepo->saveNewRole($roleData);
+    }
+
 }
\ No newline at end of file
diff --git a/tests/TestCase.php b/tests/TestCase.php
index e0f160eed..939a1a91e 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -2,13 +2,13 @@
 
 use Illuminate\Foundation\Testing\DatabaseTransactions;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
-use Illuminate\Foundation\Testing\TestResponse;
 
 abstract class TestCase extends BaseTestCase
 {
     use CreatesApplication;
     use DatabaseTransactions;
     use SharedTestHelpers;
+
     /**
      * The base URL to use while testing the application.
      * @var string
@@ -18,11 +18,46 @@ abstract class TestCase extends BaseTestCase
     /**
      * Assert a permission error has occurred.
      * @param TestResponse $response
+     * @return TestCase
      */
     protected function assertPermissionError(TestResponse $response)
     {
         $response->assertRedirect('/');
-        $this->assertTrue(session()->has('error'));
+        $this->assertSessionHas('error');
         session()->remove('error');
+        return $this;
+    }
+
+    /**
+     * Assert the session contains a specific entry.
+     * @param string $key
+     * @return $this
+     */
+    protected function assertSessionHas(string $key)
+    {
+        $this->assertTrue(session()->has($key), "Session does not contain a [{$key}] entry");
+        return $this;
+    }
+
+    /**
+     * Override of the get method so we can get visibility of custom TestResponse methods.
+     * @param  string  $uri
+     * @param  array  $headers
+     * @return TestResponse
+     */
+    public function get($uri, array $headers = [])
+    {
+        return parent::get($uri, $headers);
+    }
+
+    /**
+     * Create the test response instance from the given response.
+     *
+     * @param  \Illuminate\Http\Response $response
+     * @return TestResponse
+     */
+    protected function createTestResponse($response)
+    {
+        return TestResponse::fromBaseResponse($response);
     }
 }
\ No newline at end of file
diff --git a/tests/TestResponse.php b/tests/TestResponse.php
new file mode 100644
index 000000000..a68a5783f
--- /dev/null
+++ b/tests/TestResponse.php
@@ -0,0 +1,141 @@
+<?php namespace Tests;
+
+use \Illuminate\Foundation\Testing\TestResponse as BaseTestResponse;
+use Symfony\Component\DomCrawler\Crawler;
+use PHPUnit\Framework\Assert as PHPUnit;
+
+/**
+ * Class TestResponse
+ * Custom extension of the default Laravel TestResponse class.
+ * @package Tests
+ */
+class TestResponse extends BaseTestResponse {
+
+    protected $crawlerInstance;
+
+    /**
+     * Get the DOM Crawler for the response content.
+     * @return Crawler
+     */
+    protected function crawler()
+    {
+        if (!is_object($this->crawlerInstance)) {
+            $this->crawlerInstance = new Crawler($this->getContent());
+        }
+        return $this->crawlerInstance;
+    }
+
+    /**
+     * Assert the response contains the specified element.
+     * @param string $selector
+     * @return $this
+     */
+    public function assertElementExists(string $selector)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() > 0,
+            'Unable to find element matching the selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+        return $this;
+    }
+
+    /**
+     * Assert the response does not contain the specified element.
+     * @param string $selector
+     * @return $this
+     */
+    public function assertElementNotExists(string $selector)
+    {
+        $elements = $this->crawler()->filter($selector);
+        PHPUnit::assertTrue(
+            $elements->count() === 0,
+            'Found elements matching the selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+        return $this;
+    }
+
+    /**
+     * Assert the response includes a specific element containing the given text.
+     * @param string $selector
+     * @param string $text
+     * @return $this
+     */
+    public function assertElementContains(string $selector, string $text)
+    {
+        $elements = $this->crawler()->filter($selector);
+        $matched = false;
+        $pattern = $this->getEscapedPattern($text);
+        foreach ($elements as $element) {
+            $element = new Crawler($element);
+            if (preg_match("/$pattern/i", $element->html())) {
+                $matched = true;
+                break;
+            }
+        }
+
+        PHPUnit::assertTrue(
+            $matched,
+            'Unable to find element of selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'containing text'.PHP_EOL.PHP_EOL.
+            "[{$text}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Assert the response does not include a specific element containing the given text.
+     * @param string $selector
+     * @param string $text
+     * @return $this
+     */
+    public function assertElementNotContains(string $selector, string $text)
+    {
+        $elements = $this->crawler()->filter($selector);
+        $matched = false;
+        $pattern = $this->getEscapedPattern($text);
+        foreach ($elements as $element) {
+            $element = new Crawler($element);
+            if (preg_match("/$pattern/i", $element->html())) {
+                $matched = true;
+                break;
+            }
+        }
+
+        PHPUnit::assertTrue(
+            !$matched,
+            'Found element of selector: '.PHP_EOL.PHP_EOL.
+            "[{$selector}]".PHP_EOL.PHP_EOL.
+            'containing text'.PHP_EOL.PHP_EOL.
+            "[{$text}]".PHP_EOL.PHP_EOL.
+            'within'.PHP_EOL.PHP_EOL.
+            "[{$this->getContent()}]."
+        );
+
+        return $this;
+    }
+
+    /**
+     * Get the escaped text pattern for the constraint.
+     * @param  string  $text
+     * @return string
+     */
+    protected function getEscapedPattern($text)
+    {
+        $rawPattern = preg_quote($text, '/');
+        $escapedPattern = preg_quote(e($text), '/');
+        return $rawPattern == $escapedPattern
+            ? $rawPattern : "({$rawPattern}|{$escapedPattern})";
+    }
+
+}