diff --git a/cmd/prose/errorCatcher.go b/cmd/prose/errorCatcher.go
deleted file mode 100644
index fda356b..0000000
--- a/cmd/prose/errorCatcher.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package main
-
-import (
-	"net/http"
-	"strconv"
-
-	"github.com/aymerick/raymond"
-)
-
-// errorCatcher is a wrapper for http.ResponseWriter that
-// captures 4xx and 5xx status codes and handles them in
-// a custom manner
-type errorCatcher struct {
-	req          *http.Request
-	res          http.ResponseWriter
-	errorTpl     *raymond.Template
-	notFoundTpl  *raymond.Template
-	handledError bool
-}
-
-func (ec *errorCatcher) Header() http.Header {
-	return ec.res.Header()
-}
-
-func (ec *errorCatcher) Write(buf []byte) (int, error) {
-	// if we have already sent a response, pretend that this was successful
-	if ec.handledError {
-		return len(buf), nil
-	}
-	return ec.res.Write(buf)
-}
-
-func (ec *errorCatcher) WriteHeader(statusCode int) {
-	if ec.handledError {
-		return
-	}
-	if statusCode == 404 {
-		ctx := map[string]string{
-			"path": ec.req.URL.Path,
-		}
-		page, err := ec.notFoundTpl.Exec(ctx)
-		// if we don't have a page to write, return before
-		// we toggle the flag so we fall back to the original
-		// error page
-		if err != nil {
-			return
-		}
-		ec.res.Header().Set("Content-Type", "text/html; charset=utf-8")
-		ec.res.WriteHeader(statusCode)
-		ec.res.Write([]byte(page))
-		ec.handledError = true
-		return
-	}
-
-	if statusCode >= 400 && statusCode < 600 {
-		ctx := map[string]string{
-			"code": strconv.Itoa(statusCode),
-		}
-		page, err := ec.errorTpl.Exec(ctx)
-		// if we don't have a page to write, return before
-		// we toggle the flag so we fall back to the original
-		// error page
-		if err != nil {
-			return
-		}
-		ec.res.Header().Set("Content-Type", "text/html; charset=utf-8")
-		ec.res.WriteHeader(statusCode)
-		ec.res.Write([]byte(page))
-		ec.handledError = true
-		return
-	}
-
-	ec.res.WriteHeader(statusCode)
-}
diff --git a/cmd/prose/listener.go b/cmd/prose/listener.go
deleted file mode 100644
index 5dc0e4c..0000000
--- a/cmd/prose/listener.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package main
-
-import (
-	"log"
-	"os"
-	"runtime"
-	"strings"
-
-	"github.com/rjeczalik/notify"
-)
-
-type listener struct {
-	folder string
-	update func(string) error
-	clean  func(string) error
-}
-
-func (l *listener) listen() {
-	cwd, err := os.Getwd()
-	if err != nil {
-		log.Fatal("could not get current working directory for listener!")
-	}
-	cwd = cwd + "/"
-
-	c := make(chan notify.EventInfo, 1)
-
-	var events []notify.Event
-
-	// inotify events prevent double-firing of
-	// certain events in Linux.
-	if runtime.GOOS == "linux" {
-		events = []notify.Event{
-			notify.InCloseWrite,
-			notify.InMovedFrom,
-			notify.InMovedTo,
-			notify.InDelete,
-		}
-	} else {
-		events = []notify.Event{
-			notify.Create,
-			notify.Remove,
-			notify.Rename,
-			notify.Write,
-		}
-	}
-
-	err = notify.Watch(l.folder, c, events...)
-
-	if err != nil {
-		log.Fatalf("Could not setup watcher for folder %s: %s", l.folder, err)
-	}
-
-	defer notify.Stop(c)
-
-	for {
-		ei := <-c
-		log.Printf("event: %s", ei.Event())
-		switch ei.Event() {
-		case notify.InCloseWrite, notify.InMovedTo, notify.Create, notify.Rename, notify.Write:
-			filePath := strings.TrimPrefix(ei.Path(), cwd)
-			log.Printf("updating file %s", filePath)
-			err := l.update(strings.TrimPrefix(filePath, l.folder))
-			if err != nil {
-				log.Printf("watcher update action on %s failed: %v", filePath, err)
-			}
-		case notify.InMovedFrom, notify.InDelete, notify.Remove:
-			filePath := strings.TrimPrefix(ei.Path(), cwd)
-			log.Printf("cleaning file %s", filePath)
-			err := l.clean(strings.TrimPrefix(filePath, l.folder))
-			if err != nil {
-				log.Printf("watcher clean action on %s failed: %v", filePath, err)
-			}
-		}
-	}
-}
diff --git a/cmd/prose/post.go b/cmd/prose/post.go
deleted file mode 100644
index f4d5aec..0000000
--- a/cmd/prose/post.go
+++ /dev/null
@@ -1,191 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"fmt"
-	"io"
-	"log"
-	"os"
-	"sort"
-	"strings"
-
-	"github.com/aymerick/raymond"
-	"github.com/mitchellh/mapstructure"
-	"github.com/yuin/goldmark"
-	emoji "github.com/yuin/goldmark-emoji"
-	highlighting "github.com/yuin/goldmark-highlighting"
-	meta "github.com/yuin/goldmark-meta"
-	"github.com/yuin/goldmark/extension"
-	"github.com/yuin/goldmark/parser"
-	"github.com/yuin/goldmark/renderer/html"
-)
-
-// Metadata stores the data about a post that needs to be visible
-// at the home page.
-type Metadata struct {
-	Title   string
-	Summary string
-	Time    int64 // unix timestamp
-}
-
-// Post stores the contents of a blog post.
-type Post struct {
-	Slug     string
-	Metadata Metadata
-	Contents string
-	Image    []byte
-}
-
-func newPost(slug string) (*Post, error) {
-	data, err := os.ReadFile("posts/" + slug + ".md")
-	if err != nil {
-		return nil, fmt.Errorf("could not read file: %s", err)
-	}
-
-	md := goldmark.New(
-		goldmark.WithExtensions(
-			extension.Linkify,
-			extension.Strikethrough,
-			extension.Typographer,
-			extension.Footnote,
-			meta.Meta,
-			highlighting.Highlighting,
-			emoji.New(emoji.WithRenderingMethod(emoji.Unicode)),
-		),
-		goldmark.WithRendererOptions(
-			html.WithUnsafe(),
-		),
-	)
-	var converted bytes.Buffer
-	ctx := parser.NewContext()
-	err = md.Convert(data, &converted, parser.WithContext(ctx))
-	if err != nil {
-		return nil, fmt.Errorf("could not parse markdown: %s", err)
-	}
-	mdMap, err := meta.TryGet(ctx)
-	if err != nil {
-		return nil, fmt.Errorf("could not parse metadata: %s", err)
-	}
-	var metadata Metadata
-	err = mapstructure.Decode(mdMap, &metadata)
-	if err != nil {
-		return nil, fmt.Errorf("could not destructure metadata: %s", err)
-	}
-
-	post := &Post{
-		Slug:     slug,
-		Metadata: metadata,
-		Contents: converted.String(),
-	}
-
-	url := blogURL + "/" + slug
-	var buf bytes.Buffer
-	err = createImage(post.Metadata.Title, post.Metadata.Summary, url, &buf)
-	if err != nil {
-		return nil, fmt.Errorf("could not create post image: %v", err)
-	}
-	post.Image, err = io.ReadAll(&buf)
-	if err != nil {
-		return nil, err
-	}
-
-	return post, nil
-}
-
-func (p *Post) render(tpl *raymond.Template) (string, error) {
-	return tpl.Exec(p)
-}
-
-func (p *Post) String() string {
-	return p.Slug
-}
-
-type postList []*Post
-
-func newPostList() (postList, error) {
-	files, err := os.ReadDir("posts/")
-	if err != nil {
-		return nil, err
-	}
-
-	pl := make(postList, 0, len(files))
-	for _, f := range files {
-		filename := f.Name()
-
-		if strings.HasSuffix(filename, ".md") {
-			post, err := newPost(strings.TrimSuffix(filename, ".md"))
-			if err != nil {
-				return nil, fmt.Errorf("could not render %s: %s", filename, err)
-			}
-			pl = append(pl, post)
-			log.Printf("Loaded post %s", filename)
-		}
-	}
-	sort.Sort(pl)
-
-	return pl, nil
-}
-
-func insertOrUpdatePost(pl postList, p *Post) postList {
-	for i, post := range pl {
-		if post.Slug == p.Slug {
-			pl[i] = p
-			sort.Sort(pl)
-			return pl
-		}
-	}
-	pl = append(pl, p)
-	sort.Sort(pl)
-	return pl
-}
-
-func removePost(pl postList, slug string) postList {
-	for i, post := range pl {
-		if post.Slug == slug {
-			pl = append(pl[:i], pl[i+1:]...)
-			break
-		}
-	}
-	fmt.Println(pl)
-	return pl
-}
-
-// Len implements sort.Interface
-func (pl postList) Len() int {
-	return len(pl)
-}
-
-// Less implements sort.Interface
-func (pl postList) Less(i, j int) bool {
-	return pl[i].Metadata.Time > pl[j].Metadata.Time
-}
-
-// Swap implements sort.Interface
-func (pl postList) Swap(i, j int) {
-	temp := pl[i]
-	pl[i] = pl[j]
-	pl[j] = temp
-}
-
-func newPostListener(update func(func(postList) postList)) *listener {
-	ln := &listener{
-		folder: "posts/",
-		update: func(file string) error {
-			post, err := newPost(strings.TrimSuffix(file, ".md"))
-			if err != nil {
-				return err
-			}
-			update(func(oldList postList) postList {
-				return insertOrUpdatePost(oldList, post)
-			})
-			return nil
-		},
-		clean: func(file string) error {
-			update(func(oldList postList) postList {
-				return removePost(oldList, strings.TrimSuffix(file, ".md"))
-			})
-			return nil
-		},
-	}
-	return ln
-}
diff --git a/cmd/prose/prose.go b/cmd/prose/prose.go
index 94d736b..21302ae 100644
--- a/cmd/prose/prose.go
+++ b/cmd/prose/prose.go
@@ -3,18 +3,17 @@ package main
 import (
 	"log"
 	"net/http"
+	"prose/server"
 )
 
 func main() {
 	log.Printf("Hello, world! This is Prose.")
 
-	s, err := newServer()
+	err := server.New()
 
 	if err != nil {
 		log.Fatal(err)
 	}
 
-	http.HandleFunc("/", s.router)
-
 	log.Fatal(http.ListenAndServe(":8080", nil))
 }
diff --git a/cmd/prose/server.go b/cmd/prose/server.go
deleted file mode 100644
index d5480e7..0000000
--- a/cmd/prose/server.go
+++ /dev/null
@@ -1,316 +0,0 @@
-package main
-
-import (
-	"bytes"
-	"io"
-	"log"
-	"net/http"
-	"strings"
-	"sync"
-	"time"
-
-	"github.com/aymerick/raymond"
-	"github.com/fogleman/gg"
-)
-
-const (
-	blogTitle   = "Prose"
-	blogURL     = "https://prose.nsood.in"
-	blogSummary = "Where I infodump in Markdown and nobody can stop me."
-)
-
-type server struct {
-	staticHandler http.Handler
-
-	mu        sync.RWMutex
-	templates map[string]*raymond.Template
-	postList
-	styles    map[string]string
-	homeImage []byte
-}
-
-func newServer() (*server, error) {
-	s := &server{
-		staticHandler: http.FileServer(http.Dir("static/")),
-	}
-
-	var imgBuffer bytes.Buffer
-	err := createImage(blogTitle, blogSummary, blogURL, &imgBuffer)
-	if err != nil {
-		return nil, err
-	}
-	s.homeImage, err = io.ReadAll(&imgBuffer)
-	if err != nil {
-		return nil, err
-	}
-
-	posts, err := newPostList()
-	if err != nil {
-		return nil, err
-	}
-	s.mu.Lock()
-	s.postList = posts
-	s.mu.Unlock()
-
-	tpls, err := loadTemplates([]string{
-		"page.html",
-		"fullpost.html",
-		"summary.html",
-		"notfound.html",
-		"error.html",
-		"rss-channel.xml",
-		"rss-item.xml",
-	})
-	if err != nil {
-		return nil, err
-	}
-	s.mu.Lock()
-	s.templates = tpls
-	s.mu.Unlock()
-
-	styles, err := newStylesMap()
-	if err != nil {
-		return nil, err
-	}
-	s.mu.Lock()
-	s.styles = styles
-	s.mu.Unlock()
-
-	postsLn := newPostListener(func(updateFn func(postList) postList) {
-		s.mu.Lock()
-		defer s.mu.Unlock()
-		s.postList = updateFn(s.postList)
-	})
-	go postsLn.listen()
-
-	templatesLn := newTemplateListener(func(updateFn func(map[string]*raymond.Template)) {
-		s.mu.Lock()
-		defer s.mu.Unlock()
-		updateFn(s.templates)
-	})
-	go templatesLn.listen()
-
-	stylesLn := newStylesListener(func(updateFn func(map[string]string)) {
-		s.mu.Lock()
-		defer s.mu.Unlock()
-		updateFn(s.styles)
-	})
-	go stylesLn.listen()
-
-	return s, nil
-}
-
-func (s *server) logRequest(req *http.Request) {
-	log.Printf("%s %s from %s", req.Method, req.URL.Path, req.RemoteAddr)
-}
-
-func (s *server) router(res http.ResponseWriter, req *http.Request) {
-	s.mu.RLock()
-	defer s.mu.RUnlock()
-	s.logRequest(req)
-	res = &errorCatcher{
-		res:          res,
-		req:          req,
-		errorTpl:     s.templates["error.html"],
-		notFoundTpl:  s.templates["notfound.html"],
-		handledError: false,
-	}
-	slug := req.URL.Path[1:]
-
-	if slug == "" {
-		s.homePage(res, req)
-		return
-	}
-	if slug == "about.png" {
-		s.renderImage(res, req, s.homeImage)
-		return
-	}
-	if slug == "rss.xml" {
-		s.renderRSS(res, req)
-		return
-	}
-
-	for _, p := range s.postList {
-		if slug == p.Slug {
-			s.postPage(p, res, req)
-			return
-		} else if slug == p.Slug+"/about.png" {
-			s.renderImage(res, req, p.Image)
-			return
-		}
-	}
-
-	if strings.HasPrefix(slug, "css/") {
-		filename := strings.TrimPrefix(slug, "css/")
-		ok := s.loadStylesheet(res, req, filename)
-		if ok {
-			return
-		}
-	}
-
-	s.staticHandler.ServeHTTP(res, req)
-}
-
-func (s *server) errorInRequest(res http.ResponseWriter, req *http.Request, err error) {
-	res.WriteHeader(http.StatusInternalServerError)
-	res.Write([]byte("oh no"))
-	log.Printf("ERR %s: %s", req.URL.Path, err)
-}
-
-func (s *server) createWebPage(title, subtitle, contents, path string) (string, error) {
-	ctx := map[string]interface{}{
-		"title":    title,
-		"subtitle": subtitle,
-		"contents": contents,
-		"path":     blogURL + path,
-	}
-	return s.templates["page.html"].Exec(ctx)
-}
-
-func (s *server) postPage(p *Post, res http.ResponseWriter, req *http.Request) {
-	res.Header().Add("content-type", "text/html; charset=utf-8")
-	contents, err := p.render(s.templates["fullpost.html"])
-	if err != nil {
-		s.errorInRequest(res, req, err)
-	}
-	page, err := s.createWebPage(p.Metadata.Title, p.Metadata.Summary, contents, req.URL.Path)
-	if err != nil {
-		s.errorInRequest(res, req, err)
-	}
-	res.Write([]byte(page))
-}
-
-func (s *server) homePage(res http.ResponseWriter, req *http.Request) {
-	res.Header().Add("content-type", "text/html; charset=utf-8")
-
-	var posts string
-
-	for _, p := range s.postList {
-		summary, err := p.render(s.templates["summary.html"])
-		if err != nil {
-			log.Printf("could not render post summary for %s", p.Slug)
-		}
-		posts = posts + summary
-	}
-
-	page, err := s.createWebPage("Home", blogSummary, posts, "")
-
-	if err != nil {
-		s.errorInRequest(res, req, err)
-	}
-
-	res.Write([]byte(page))
-}
-
-func (s *server) renderImage(res http.ResponseWriter, req *http.Request, img []byte) {
-	res.Header().Add("content-type", "image/png")
-	res.Write(img)
-}
-
-func (s *server) renderRSS(res http.ResponseWriter, req *http.Request) {
-	res.Header().Add("content-type", "text/xml; charset=utf-8")
-
-	var posts string
-
-	for _, p := range s.postList {
-		summary, err := p.render(s.templates["rss-item.xml"])
-		if err != nil {
-			log.Printf("could not render post summary for %s", p.Slug)
-		}
-		posts = posts + summary
-	}
-
-	var pubDate string
-	if len(posts) > 0 {
-		pubDate = rssDatetime(s.postList[0].Metadata.Time)
-	} else {
-		pubDate = rssDatetime(0)
-	}
-
-	page, err := s.templates["rss-channel.xml"].Exec(map[string]string{
-		"title":       blogTitle,
-		"description": blogSummary,
-		"link":        blogURL,
-		"pubDate":     pubDate,
-		"items":       posts,
-	})
-
-	if err != nil {
-		s.errorInRequest(res, req, err)
-	}
-
-	res.Write([]byte(page))
-}
-
-func (s *server) loadStylesheet(res http.ResponseWriter, req *http.Request, filename string) (ok bool) {
-	contents, ok := s.styles[filename]
-	if !ok {
-		return false
-	}
-	res.Header().Add("content-type", "text/css")
-	res.Write([]byte(contents))
-	return ok
-}
-
-func rssDatetime(timestamp int64) string {
-	return time.Unix(timestamp, 0).Format("Mon, 02 Jan 2006 15:04:05 MST")
-}
-
-func createImage(title, summary, url string, out io.Writer) error {
-	imgWidth, imgHeight, imgPaddingX, imgPaddingY := 1200, 600, 50, 100
-	accentHeight, spacerHeight := 12.5, 20.0
-	titleSize, summarySize, urlSize := 63.0, 42.0, 27.0
-	lineHeight := 1.05
-	textWidth := float64(imgWidth - 2*imgPaddingX)
-
-	draw := gg.NewContext(imgWidth, imgHeight)
-
-	titleFont, err := gg.LoadFontFace("static/fonts/Nunito-Bold.ttf", titleSize)
-	if err != nil {
-		return err
-	}
-	summaryFont, err := gg.LoadFontFace("static/fonts/Nunito-LightItalic.ttf", summarySize)
-	if err != nil {
-		return err
-	}
-	urlFont, err := gg.LoadFontFace("static/fonts/JetBrainsMono-ExtraLight.ttf", urlSize)
-	if err != nil {
-		return err
-	}
-
-	draw.SetFontFace(titleFont)
-	wrappedTitle := draw.WordWrap(title, textWidth)
-	draw.SetFontFace(summaryFont)
-	wrappedSummary := draw.WordWrap(summary, textWidth)
-
-	draw.SetHexColor("#fff")
-	draw.DrawRectangle(0, 0, float64(imgWidth), float64(imgHeight))
-	draw.Fill()
-	draw.SetHexColor("#3498db")
-	draw.DrawRectangle(0, float64(imgHeight)-accentHeight, float64(imgWidth), accentHeight)
-	draw.Fill()
-
-	offset := float64(imgPaddingY)
-
-	draw.SetFontFace(titleFont)
-	draw.SetHexColor("#333")
-	for _, line := range wrappedTitle {
-		draw.DrawString(line, float64(imgPaddingX), offset)
-		offset += lineHeight * titleSize
-	}
-	offset += spacerHeight
-
-	draw.SetFontFace(summaryFont)
-	draw.SetHexColor("#999")
-	for _, line := range wrappedSummary {
-		draw.DrawString(line, float64(imgPaddingX), offset)
-		offset += lineHeight * summarySize
-	}
-
-	draw.SetHexColor("#333")
-	draw.SetFontFace(urlFont)
-	urlY := float64(imgHeight - imgPaddingY)
-	draw.DrawStringWrapped(url, float64(imgPaddingX), urlY, 0, 0, textWidth, lineHeight, gg.AlignRight)
-
-	return draw.EncodePNG(out)
-}
diff --git a/cmd/prose/style.go b/cmd/prose/style.go
deleted file mode 100644
index 35f3fd2..0000000
--- a/cmd/prose/style.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"io"
-	"log"
-	"os"
-	"strings"
-
-	"github.com/bep/godartsass/v2"
-)
-
-var sassTranspiler *godartsass.Transpiler
-
-func newStylesMap() (map[string]string, error) {
-	folder, err := os.ReadDir("styles/")
-	if err != nil {
-		return nil, fmt.Errorf("could not load styles directory: %s", err)
-	}
-
-	styles := make(map[string]string)
-	for _, s := range folder {
-		contents, filename, err := loadStylesheet(s.Name())
-		if err != nil {
-			return nil, fmt.Errorf("could not generate styles for %s: %v", s.Name(), err)
-		}
-		styles[filename] = contents
-		log.Printf("Loaded stylesheet %s", filename)
-	}
-
-	return styles, nil
-}
-
-func newStylesListener(updateMap func(func(map[string]string))) *listener {
-	ln := &listener{
-		folder: "styles/",
-		update: func(file string) error {
-			contents, filename, err := loadStylesheet(file)
-			if err != nil {
-				return err
-			}
-			updateMap(func(styles map[string]string) {
-				styles[filename] = contents
-			})
-			return nil
-		},
-		clean: func(file string) error {
-			updateMap(func(styles map[string]string) {
-				delete(styles, file+".css")
-			})
-			return nil
-		},
-	}
-	return ln
-}
-
-func loadStylesheet(filename string) (string, string, error) {
-	if strings.HasSuffix(filename, ".scss") {
-		return loadSCSS(filename)
-	}
-	return loadCSS(filename)
-}
-
-func loadSCSS(filename string) (string, string, error) {
-	in, err := os.Open("styles/" + filename)
-	if err != nil {
-		return "", "", fmt.Errorf("could not open stylesheet %s: %w", filename, err)
-	}
-	stylesheet, err := io.ReadAll(in)
-	if err != nil {
-		return "", "", fmt.Errorf("could not read stylesheet %s: %w", filename, err)
-	}
-	if sassTranspiler == nil {
-		sassTranspiler, err = godartsass.Start(godartsass.Options{})
-		if err != nil {
-			return "", "", fmt.Errorf("could not start sass transpiler: %w", err)
-		}
-	}
-	res, err := sassTranspiler.Execute(godartsass.Args{
-		Source: string(stylesheet),
-	})
-	if err != nil {
-		return "", "", fmt.Errorf("could not generate stylesheet %s: %w", filename, err)
-	}
-	return res.CSS, strings.TrimSuffix(filename, ".scss") + ".css", nil
-}
-
-func loadCSS(filename string) (string, string, error) {
-	in, err := os.Open("styles/" + filename)
-	if err != nil {
-		return "", "", fmt.Errorf("could not open style infile %s: %w", filename, err)
-	}
-	var buf strings.Builder
-	_, err = io.Copy(&buf, in)
-	if err != nil {
-		return "", "", fmt.Errorf("could not copy stylesheet %s: %s", filename, err)
-	}
-	return buf.String(), filename, nil
-}
diff --git a/cmd/prose/template.go b/cmd/prose/template.go
deleted file mode 100644
index 5e27568..0000000
--- a/cmd/prose/template.go
+++ /dev/null
@@ -1,78 +0,0 @@
-package main
-
-import (
-	"fmt"
-	"log"
-	"strconv"
-	"time"
-
-	"github.com/aymerick/raymond"
-)
-
-func loadTemplate(file string) (*raymond.Template, error) {
-	tpl, err := raymond.ParseFile("templates/" + file)
-	if err != nil {
-		return nil, fmt.Errorf("could not parse %s template: %w", file, err)
-	}
-	tpl.RegisterHelper("datetime", func(timeStr string) string {
-		timestamp, err := strconv.ParseInt(timeStr, 10, 64)
-		if err != nil {
-			log.Printf("Could not parse timestamp '%v', falling back to current time", timeStr)
-			timestamp = time.Now().Unix()
-		}
-		return time.Unix(timestamp, 0).Format("Jan 2 2006, 3:04 PM")
-	})
-	tpl.RegisterHelper("rssDatetime", func(timeStr string) string {
-		timestamp, err := strconv.ParseInt(timeStr, 10, 64)
-		if err != nil {
-			log.Printf("Could not parse timestamp '%v', falling back to current time", timeStr)
-			timestamp = time.Now().Unix()
-		}
-		return rssDatetime(timestamp)
-	})
-	tpl.RegisterHelper("getFullUrl", func(slug string) string {
-		return blogURL + "/" + slug
-	})
-	log.Printf("Loaded template: %s", file)
-	return tpl, nil
-}
-
-// loadTemplates, for each f in files, loads `templates/$f`
-// as a handlebars HTML/XML template. If any single template fails to
-// load, only an error is returned. Conversely, if there is no error,
-// every template name passed is guaranteed to have loaded successfully.
-func loadTemplates(files []string) (map[string]*raymond.Template, error) {
-	templates := make(map[string]*raymond.Template)
-	for _, f := range files {
-		tpl, err := loadTemplate(f)
-		if err != nil {
-			return nil, err
-		}
-		templates[f] = tpl
-	}
-	log.Printf("Loaded templates: %s", files)
-	return templates, nil
-}
-
-func newTemplateListener(update func(func(map[string]*raymond.Template))) *listener {
-	return &listener{
-		folder: "templates/",
-		update: func(file string) error {
-			newTpl, err := loadTemplate(file)
-			if err != nil {
-				return err
-			}
-			update(func(oldMap map[string]*raymond.Template) {
-				oldMap[file] = newTpl
-			})
-			return nil
-		},
-		clean: func(file string) error {
-			update(func(oldMap map[string]*raymond.Template) {
-				delete(oldMap, file)
-			})
-			log.Printf("Unloaded template: %s", file)
-			return nil
-		},
-	}
-}
diff --git a/common/common.go b/common/common.go
new file mode 100644
index 0000000..bef2a3c
--- /dev/null
+++ b/common/common.go
@@ -0,0 +1,77 @@
+package common
+
+import (
+	"io"
+	"time"
+
+	"github.com/fogleman/gg"
+)
+
+const (
+	BlogTitle   = "Prose"
+	BlogURL     = "https://prose.nsood.in"
+	BlogSummary = "Where I infodump in Markdown and nobody can stop me."
+)
+
+func RSSDatetime(timestamp int64) string {
+	return time.Unix(timestamp, 0).Format("Mon, 02 Jan 2006 15:04:05 MST")
+}
+
+func CreateImage(title, summary, url string, out io.Writer) error {
+	imgWidth, imgHeight, imgPaddingX, imgPaddingY := 1200, 600, 50, 100
+	accentHeight, spacerHeight := 12.5, 20.0
+	titleSize, summarySize, urlSize := 63.0, 42.0, 27.0
+	lineHeight := 1.05
+	textWidth := float64(imgWidth - 2*imgPaddingX)
+
+	draw := gg.NewContext(imgWidth, imgHeight)
+
+	titleFont, err := gg.LoadFontFace("static/fonts/Nunito-Bold.ttf", titleSize)
+	if err != nil {
+		return err
+	}
+	summaryFont, err := gg.LoadFontFace("static/fonts/Nunito-LightItalic.ttf", summarySize)
+	if err != nil {
+		return err
+	}
+	urlFont, err := gg.LoadFontFace("static/fonts/JetBrainsMono-ExtraLight.ttf", urlSize)
+	if err != nil {
+		return err
+	}
+
+	draw.SetFontFace(titleFont)
+	wrappedTitle := draw.WordWrap(title, textWidth)
+	draw.SetFontFace(summaryFont)
+	wrappedSummary := draw.WordWrap(summary, textWidth)
+
+	draw.SetHexColor("#fff")
+	draw.DrawRectangle(0, 0, float64(imgWidth), float64(imgHeight))
+	draw.Fill()
+	draw.SetHexColor("#3498db")
+	draw.DrawRectangle(0, float64(imgHeight)-accentHeight, float64(imgWidth), accentHeight)
+	draw.Fill()
+
+	offset := float64(imgPaddingY)
+
+	draw.SetFontFace(titleFont)
+	draw.SetHexColor("#333")
+	for _, line := range wrappedTitle {
+		draw.DrawString(line, float64(imgPaddingX), offset)
+		offset += lineHeight * titleSize
+	}
+	offset += spacerHeight
+
+	draw.SetFontFace(summaryFont)
+	draw.SetHexColor("#999")
+	for _, line := range wrappedSummary {
+		draw.DrawString(line, float64(imgPaddingX), offset)
+		offset += lineHeight * summarySize
+	}
+
+	draw.SetHexColor("#333")
+	draw.SetFontFace(urlFont)
+	urlY := float64(imgHeight - imgPaddingY)
+	draw.DrawStringWrapped(url, float64(imgPaddingX), urlY, 0, 0, textWidth, lineHeight, gg.AlignRight)
+
+	return draw.EncodePNG(out)
+}
diff --git a/errorcatcher/errorcatcher.go b/errorcatcher/errorcatcher.go
new file mode 100644
index 0000000..6d077db
--- /dev/null
+++ b/errorcatcher/errorcatcher.go
@@ -0,0 +1,84 @@
+package errorcatcher
+
+import (
+	"net/http"
+	"prose/watcher"
+	"strconv"
+
+	"github.com/aymerick/raymond"
+)
+
+// errorCatcher is a wrapper for http.ResponseWriter that
+// captures 4xx and 5xx status codes and handles them in
+// a custom manner
+type errorCatcher struct {
+	r         *http.Request
+	w         http.ResponseWriter
+	templates watcher.AutoMap[string, *raymond.Template]
+	handled   bool
+}
+
+func New(w http.ResponseWriter, r *http.Request, templates watcher.AutoMap[string, *raymond.Template]) *errorCatcher {
+	return &errorCatcher{
+		r:         r,
+		w:         w,
+		templates: templates,
+	}
+}
+
+func (ec *errorCatcher) Header() http.Header {
+	return ec.w.Header()
+}
+
+func (ec *errorCatcher) Write(buf []byte) (int, error) {
+	// if we have already sent a response, pretend that this was successful
+	if ec.handled {
+		return len(buf), nil
+	}
+	return ec.w.Write(buf)
+}
+
+func (ec *errorCatcher) WriteHeader(statusCode int) {
+	if ec.handled {
+		return
+	}
+
+	if statusCode == http.StatusNotFound {
+		tpl, _ := ec.templates.Get("notfound.html")
+		page, err := tpl.Exec(map[string]string{
+			"path": ec.r.URL.Path,
+		})
+		// if we don't have a page to write, return before
+		// we toggle the flag so we fall back to the original
+		// error page
+		if err != nil {
+			return
+		}
+		ec.w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		ec.w.WriteHeader(statusCode)
+		ec.w.Write([]byte(page))
+		ec.handled = true
+		return
+	}
+
+	if statusCode >= 400 && statusCode < 600 {
+		ctx := map[string]string{
+			"code": strconv.Itoa(statusCode),
+		}
+		tpl, _ := ec.templates.Get("error.html")
+		page, err := tpl.Exec(ctx)
+		// if we don't have a page to write, return before
+		// we toggle the flag so we fall back to the original
+		// error page
+		if err != nil {
+			return
+		}
+		ec.w.Header().Set("Content-Type", "text/html; charset=utf-8")
+		ec.w.WriteHeader(statusCode)
+		ec.w.Write([]byte(page))
+		ec.handled = true
+		return
+	}
+
+	ec.w.WriteHeader(statusCode)
+}
diff --git a/go.mod b/go.mod
index 1fe6838..e7407e2 100644
--- a/go.mod
+++ b/go.mod
@@ -1,18 +1,27 @@
 module prose
 
-go 1.15
+go 1.23
 
 require (
 	github.com/aymerick/raymond v2.0.2+incompatible
 	github.com/bep/godartsass/v2 v2.0.0
 	github.com/fogleman/gg v1.3.0
-	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
 	github.com/mitchellh/mapstructure v1.4.1
 	github.com/rjeczalik/notify v0.9.2
 	github.com/yuin/goldmark v1.3.1
 	github.com/yuin/goldmark-emoji v1.0.1
 	github.com/yuin/goldmark-highlighting v0.0.0-20200307114337-60d527fdb691
 	github.com/yuin/goldmark-meta v1.0.0
+)
+
+require (
+	github.com/alecthomas/chroma v0.7.2-0.20200305040604-4f3623dce67a // indirect
+	github.com/cli/safeexec v1.0.1 // indirect
+	github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
+	github.com/dlclark/regexp2 v1.2.0 // indirect
+	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
 	golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d // indirect
 	golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46 // indirect
+	google.golang.org/protobuf v1.30.0 // indirect
+	gopkg.in/yaml.v2 v2.3.0 // indirect
 )
diff --git a/go.sum b/go.sum
index dbf7c1c..b2867e5 100644
--- a/go.sum
+++ b/go.sum
@@ -18,12 +18,10 @@ github.com/bep/godartsass/v2 v2.0.0 h1:Ruht+BpBWkpmW+yAM2dkp7RSSeN0VLaTobyW0CiSP
 github.com/bep/godartsass/v2 v2.0.0/go.mod h1:AcP8QgC+OwOXEq6im0WgDRYK7scDsmZCEW62o1prQLo=
 github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
 github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
 github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
 github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
 github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
@@ -44,11 +42,8 @@ github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
 github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
 github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
 github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
-github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
 github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
@@ -60,7 +55,6 @@ github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
 github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/rjeczalik/notify v0.9.2 h1:MiTWrPj55mNDHEiIX5YUSKefw/+lCQVoAFmD6oQm5w8=
 github.com/rjeczalik/notify v0.9.2/go.mod h1:aErll2f0sUX9PXZnVNyeiObbmTlk5jnMoCa4QEjJeqM=
@@ -70,7 +64,6 @@ github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
-github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
 github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
@@ -97,9 +90,7 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
 google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
-gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
 gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
diff --git a/postmap/postmap.go b/postmap/postmap.go
new file mode 100644
index 0000000..0657e06
--- /dev/null
+++ b/postmap/postmap.go
@@ -0,0 +1,213 @@
+package postmap
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"iter"
+	"log"
+	"os"
+	"prose/common"
+	"prose/watcher"
+	"slices"
+	"strings"
+	"sync"
+
+	"github.com/aymerick/raymond"
+	"github.com/mitchellh/mapstructure"
+	"github.com/yuin/goldmark"
+	emoji "github.com/yuin/goldmark-emoji"
+	highlighting "github.com/yuin/goldmark-highlighting"
+	meta "github.com/yuin/goldmark-meta"
+	"github.com/yuin/goldmark/extension"
+	"github.com/yuin/goldmark/parser"
+	"github.com/yuin/goldmark/renderer/html"
+)
+
+// Metadata stores the data about a post that needs to be visible
+// at the home page.
+type Metadata struct {
+	Title   string
+	Summary string
+	Time    int64 // unix timestamp
+}
+
+// Post stores the contents of a blog post.
+type Post struct {
+	Slug     string
+	Metadata Metadata
+	Contents string
+	Image    []byte
+}
+
+func (p *Post) Render(tpl *raymond.Template) (string, error) {
+	return tpl.Exec(p)
+}
+
+func (p *Post) String() string {
+	return p.Slug
+}
+
+func postLess(a, b *Post) int {
+	return int(b.Metadata.Time - a.Metadata.Time)
+}
+
+// postList stores Posts in reverse order of Post.Metadata.Time.
+type postList struct {
+	mu    sync.RWMutex
+	posts []*Post
+	md    goldmark.Markdown
+}
+
+// New returns a new postList.
+func New() (watcher.OrderedAutoMap[string, *Post], error) {
+	files, err := os.ReadDir("posts/")
+	if err != nil {
+		return nil, err
+	}
+
+	pl := &postList{
+		posts: make([]*Post, 0, len(files)),
+		md: goldmark.New(
+			goldmark.WithExtensions(
+				extension.Linkify,
+				extension.Strikethrough,
+				extension.Typographer,
+				extension.Footnote,
+				meta.Meta,
+				highlighting.Highlighting,
+				emoji.New(emoji.WithRenderingMethod(emoji.Unicode)),
+			),
+			goldmark.WithRendererOptions(
+				html.WithUnsafe(),
+			),
+		),
+	}
+	for _, f := range files {
+		filename := f.Name()
+
+		if filename == ".gitignore" {
+			continue
+		}
+
+		err := pl.fetchLocked(filename)
+		if err != nil {
+			log.Printf("could not render %q, skipping: %v", filename, err)
+		} else {
+			log.Printf("loaded post: %q", filename)
+		}
+	}
+
+	go watcher.Watch("posts/", pl)
+
+	return pl, nil
+}
+
+func (pl *postList) newPost(filename string) (*Post, error) {
+	slug, ok := strings.CutSuffix(filename, ".md")
+	if !ok {
+		return nil, fmt.Errorf("unknown file extension in posts directory: %q", filename)
+	}
+	data, err := os.ReadFile("posts/" + filename)
+	if err != nil {
+		return nil, fmt.Errorf("could not read file: %w", err)
+	}
+
+	var converted bytes.Buffer
+	ctx := parser.NewContext()
+	err = pl.md.Convert(data, &converted, parser.WithContext(ctx))
+	if err != nil {
+		return nil, fmt.Errorf("could not parse markdown: %w", err)
+	}
+	mdMap, err := meta.TryGet(ctx)
+	if err != nil {
+		return nil, fmt.Errorf("could not parse metadata: %w", err)
+	}
+	var metadata Metadata
+	err = mapstructure.Decode(mdMap, &metadata)
+	if err != nil {
+		return nil, fmt.Errorf("could not destructure metadata: %w", err)
+	}
+
+	post := &Post{
+		Slug:     slug,
+		Metadata: metadata,
+		Contents: converted.String(),
+	}
+
+	url := common.BlogURL + "/" + slug
+	var buf bytes.Buffer
+	err = common.CreateImage(post.Metadata.Title, post.Metadata.Summary, url, &buf)
+	if err != nil {
+		return nil, fmt.Errorf("could not create post image: %w", err)
+	}
+	post.Image, err = io.ReadAll(&buf)
+	if err != nil {
+		return nil, err
+	}
+
+	return post, nil
+}
+
+func (pl *postList) Get(slug string) (*Post, bool) {
+	pl.mu.RLock()
+	defer pl.mu.RUnlock()
+	for _, p := range pl.posts {
+		if p.Slug == slug {
+			return p, true
+		}
+	}
+	return nil, false
+}
+
+func (pl *postList) Fetch(filename string) error {
+	pl.mu.Lock()
+	defer pl.mu.Unlock()
+	return pl.fetchLocked(filename)
+}
+
+func (pl *postList) fetchLocked(filename string) error {
+	p, err := pl.newPost(filename)
+	if err != nil {
+		return err
+	}
+	defer slices.SortFunc(pl.posts, postLess)
+	for i, post := range pl.posts {
+		if post.Slug == p.Slug {
+			pl.posts[i] = p
+			return nil
+		}
+	}
+	pl.posts = append(pl.posts, p)
+	return nil
+}
+
+func (pl *postList) Delete(filename string) error {
+	pl.mu.Lock()
+	defer pl.mu.Unlock()
+	slug, ok := strings.CutSuffix(filename, ".md")
+	if !ok {
+		return fmt.Errorf("unknown file extension in posts directory: %q", filename)
+	}
+	for i, post := range pl.posts {
+		if post.Slug == slug {
+			pl.posts = append(pl.posts[:i], pl.posts[i+1:]...)
+			break
+		}
+	}
+	return nil
+}
+
+// All implements watcher.OrderedCompMap[string, *Post], providing Posts in
+// order of most recent first.
+func (pl *postList) All() iter.Seq2[string, *Post] {
+	return func(yield func(string, *Post) bool) {
+		pl.mu.RLock()
+		defer pl.mu.RUnlock()
+		for _, p := range pl.posts {
+			if !yield(p.Slug, p) {
+				break
+			}
+		}
+	}
+}
diff --git a/server/server.go b/server/server.go
new file mode 100644
index 0000000..03ccfa0
--- /dev/null
+++ b/server/server.go
@@ -0,0 +1,201 @@
+package server
+
+import (
+	"bytes"
+	"log"
+	"net/http"
+	"prose/common"
+	"prose/errorcatcher"
+	"prose/postmap"
+	"prose/stylemap"
+	"prose/tplmap"
+	"prose/watcher"
+	"strings"
+
+	"github.com/aymerick/raymond"
+)
+
+type server struct {
+	staticHandler http.Handler
+
+	templates watcher.AutoMap[string, *raymond.Template]
+	posts     watcher.OrderedAutoMap[string, *postmap.Post]
+	styles    watcher.AutoMap[string, string]
+	homeImage []byte
+}
+
+func New() error {
+	s := &server{
+		staticHandler: http.FileServer(http.Dir("static/")),
+	}
+
+	var imgBuffer bytes.Buffer
+	err := common.CreateImage(common.BlogTitle, common.BlogSummary, common.BlogURL, &imgBuffer)
+	if err != nil {
+		return err
+	}
+	s.homeImage = imgBuffer.Bytes()
+
+	s.posts, err = postmap.New()
+	if err != nil {
+		return err
+	}
+
+	s.templates, err = tplmap.New()
+	if err != nil {
+		return err
+	}
+
+	s.styles, err = stylemap.New()
+	if err != nil {
+		return err
+	}
+
+	handlers := map[string]http.HandlerFunc{
+		"GET /{$}":              s.serveGetHome,
+		"GET /banner.png":       s.serveGetImage(s.homeImage),
+		"GET /rss.xml":          s.serveGetRSS,
+		"GET /{slug}":           s.serveGetPost,
+		"GET /img/banner/{img}": s.serveGetPostImage,
+		"GET /css/{filename}":   s.serveGetStylesheet,
+		"GET /":                 s.staticHandler.ServeHTTP,
+	}
+
+	for pattern, handler := range handlers {
+		http.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			w = errorcatcher.New(w, r, s.templates)
+			handler(w, r)
+		}))
+	}
+
+	return nil
+}
+
+func (s *server) errorInRequest(res http.ResponseWriter, req *http.Request, err error) {
+	res.WriteHeader(http.StatusInternalServerError)
+	log.Printf("ERR %s: %s", req.URL.Path, err)
+}
+
+func (s *server) createWebPage(title, subtitle, contents, banner string) (string, error) {
+	ctx := map[string]interface{}{
+		"title":    title,
+		"subtitle": subtitle,
+		"contents": contents,
+		"banner":   banner,
+	}
+	tpl, _ := s.templates.Get("page.html")
+	return tpl.Exec(ctx)
+}
+
+func (s *server) serveGetPost(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.posts.Get(r.PathValue("slug"))
+	if !ok {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	w.Header().Add("content-type", "text/html; charset=utf-8")
+	tpl, _ := s.templates.Get("fullpost.html")
+	contents, err := p.Render(tpl)
+	if err != nil {
+		s.errorInRequest(w, r, err)
+		return
+	}
+	page, err := s.createWebPage(p.Metadata.Title, p.Metadata.Summary, contents, "/img/banner/"+p.Slug+".png")
+	if err != nil {
+		s.errorInRequest(w, r, err)
+		return
+	}
+	w.Write([]byte(page))
+}
+
+func (s *server) serveGetHome(w http.ResponseWriter, r *http.Request) {
+	w.Header().Add("content-type", "text/html; charset=utf-8")
+
+	var posts string
+
+	summaryTpl, _ := s.templates.Get("summary.html")
+	for _, p := range s.posts.All() {
+		summary, err := p.Render(summaryTpl)
+		if err != nil {
+			log.Printf("could not render post summary for %s", p.Slug)
+		}
+		posts = posts + summary
+	}
+
+	page, err := s.createWebPage("Home", common.BlogSummary, posts, "/banner.png")
+
+	if err != nil {
+		s.errorInRequest(w, r, err)
+		return
+	}
+
+	w.Write([]byte(page))
+}
+
+func (s *server) serveGetPostImage(w http.ResponseWriter, r *http.Request) {
+	slug, ok := strings.CutSuffix(r.PathValue("img"), ".png")
+	if !ok {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	post, ok := s.posts.Get(slug)
+	if !ok {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	s.serveGetImage(post.Image)(w, r)
+}
+
+func (s *server) serveGetImage(img []byte) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Add("content-type", "image/png")
+		w.Write(img)
+	}
+}
+
+func (s *server) serveGetRSS(w http.ResponseWriter, r *http.Request) {
+	w.Header().Add("content-type", "text/xml; charset=utf-8")
+
+	var posts, pubDate string
+
+	rssItemTpl, _ := s.templates.Get("rss-item.xml")
+	for _, p := range s.posts.All() {
+		if pubDate != "" {
+			pubDate = common.RSSDatetime(p.Metadata.Time)
+		}
+		summary, err := p.Render(rssItemTpl)
+		if err != nil {
+			log.Printf("could not render post summary for %s", p.Slug)
+		}
+		posts = posts + summary
+	}
+	if pubDate == "" {
+		pubDate = common.RSSDatetime(0)
+	}
+
+	rssPageTpl, _ := s.templates.Get("rss-channel.xml")
+	page, err := rssPageTpl.Exec(map[string]string{
+		"title":       common.BlogTitle,
+		"description": common.BlogSummary,
+		"link":        common.BlogURL,
+		"pubDate":     pubDate,
+		"items":       posts,
+	})
+
+	if err != nil {
+		s.errorInRequest(w, r, err)
+		return
+	}
+
+	w.Write([]byte(page))
+}
+
+func (s *server) serveGetStylesheet(w http.ResponseWriter, r *http.Request) {
+	contents, ok := s.styles.Get(r.PathValue("filename"))
+	if !ok {
+		w.WriteHeader(http.StatusNotFound)
+		return
+	}
+	w.Header().Add("content-type", "text/css")
+	w.Write([]byte(contents))
+}
diff --git a/stylemap/stylemap.go b/stylemap/stylemap.go
new file mode 100644
index 0000000..70c8377
--- /dev/null
+++ b/stylemap/stylemap.go
@@ -0,0 +1,117 @@
+package stylemap
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"prose/watcher"
+	"strings"
+	"sync"
+
+	"github.com/bep/godartsass/v2"
+)
+
+type styleMap struct {
+	mu          sync.RWMutex
+	stylesheets map[string]string
+	transpiler  *godartsass.Transpiler
+}
+
+// New returns a new styleMap.
+func New() (watcher.AutoMap[string, string], error) {
+	folder, err := os.ReadDir("styles/")
+	if err != nil {
+		return nil, fmt.Errorf("could not load styles directory: %w", err)
+	}
+
+	sassTranspiler, err := godartsass.Start(godartsass.Options{})
+	if err != nil {
+		return nil, fmt.Errorf("could not start sass transpiler: %w", err)
+	}
+
+	sm := &styleMap{
+		stylesheets: make(map[string]string),
+		transpiler:  sassTranspiler,
+	}
+
+	for _, s := range folder {
+		err := sm.fetchLocked(s.Name())
+		if err != nil {
+			return nil, fmt.Errorf("could not generate styles for %s: %w", s.Name(), err)
+		}
+		log.Printf("loaded stylesheet %q", s.Name())
+	}
+
+	go watcher.Watch("styles/", sm)
+
+	return sm, nil
+}
+
+func (sm *styleMap) Get(filename string) (string, bool) {
+	sm.mu.RLock()
+	defer sm.mu.RUnlock()
+	contents, ok := sm.stylesheets[filename]
+	return contents, ok
+}
+
+func (sm *styleMap) Delete(filename string) error {
+	sm.mu.Lock()
+	defer sm.mu.Unlock()
+	if s, ok := strings.CutSuffix(filename, ".scss"); ok {
+		filename = s + ".css"
+	}
+	delete(sm.stylesheets, filename)
+	return nil
+}
+
+func (sm *styleMap) Fetch(filename string) (err error) {
+	sm.mu.Lock()
+	defer sm.mu.Unlock()
+	return sm.fetchLocked(filename)
+}
+
+func (sm *styleMap) fetchLocked(filename string) (err error) {
+	var outfile, contents string
+	if strings.HasSuffix(filename, ".scss") {
+		outfile, contents, err = sm.loadSCSS(filename)
+	} else {
+		outfile, contents, err = sm.loadCSS(filename)
+	}
+	if err != nil {
+		return
+	}
+	sm.stylesheets[outfile] = contents
+	return
+}
+
+func (sm *styleMap) loadSCSS(filename string) (string, string, error) {
+	in, err := os.Open("styles/" + filename)
+	if err != nil {
+		return "", "", fmt.Errorf("could not open stylesheet %s: %w", filename, err)
+	}
+	stylesheet, err := io.ReadAll(in)
+	if err != nil {
+		return "", "", fmt.Errorf("could not read stylesheet %s: %w", filename, err)
+	}
+	res, err := sm.transpiler.Execute(godartsass.Args{
+		Source: string(stylesheet),
+	})
+	if err != nil {
+		return "", "", fmt.Errorf("could not generate stylesheet %s: %w", filename, err)
+	}
+	return strings.TrimSuffix(filename, ".scss") + ".css", res.CSS, nil
+}
+
+func (sm *styleMap) loadCSS(filename string) (string, string, error) {
+	in, err := os.Open("styles/" + filename)
+	if err != nil {
+		return "", "", fmt.Errorf("could not open style infile %s: %w", filename, err)
+	}
+	var buf strings.Builder
+	_, err = io.Copy(&buf, in)
+	if err != nil {
+		return "", "", fmt.Errorf("could not copy stylesheet %s: %w", filename, err)
+	}
+	return filename, buf.String(), nil
+}
diff --git a/templates/page.html b/templates/page.html
index 383264f..c199dda 100644
--- a/templates/page.html
+++ b/templates/page.html
@@ -8,7 +8,7 @@
 	<meta property="og:type" content="website">
 	<meta property="og:title" content="{{title}} – Prose">
 	<meta property="og:description" content="{{subtitle}}">
-	<meta property="og:image" content="{{path}}/about.png">
+	<meta property="og:image" content="{{banner}}">
 	<meta name="twitter:creator" content="@tendstofortytwo">
 	<meta name="twitter:card" content="summary_large_image">
 	<meta name="color-scheme" content="dark light">
diff --git a/tplmap/tplmap.go b/tplmap/tplmap.go
new file mode 100644
index 0000000..ea5ab81
--- /dev/null
+++ b/tplmap/tplmap.go
@@ -0,0 +1,90 @@
+package tplmap
+
+import (
+	"fmt"
+	"log"
+	"os"
+	"prose/common"
+	"prose/watcher"
+	"strconv"
+	"sync"
+	"time"
+
+	"github.com/aymerick/raymond"
+)
+
+type tplMap struct {
+	mu        sync.RWMutex
+	templates map[string]*raymond.Template
+}
+
+func New() (watcher.AutoMap[string, *raymond.Template], error) {
+	folder, err := os.ReadDir("templates/")
+	if err != nil {
+		return nil, fmt.Errorf("could not load templates directory: %w", err)
+	}
+
+	tm := &tplMap{
+		templates: make(map[string]*raymond.Template),
+	}
+
+	for _, s := range folder {
+		err := tm.fetchLocked(s.Name())
+		if err != nil {
+			return nil, fmt.Errorf("could not load template %q: %w", s.Name(), err)
+		}
+		log.Printf("loaded template %q", s.Name())
+	}
+
+	go watcher.Watch("templates/", tm)
+
+	return tm, nil
+}
+
+func (tm *tplMap) Get(filename string) (*raymond.Template, bool) {
+	tm.mu.RLock()
+	defer tm.mu.RUnlock()
+	got, ok := tm.templates[filename]
+	return got, ok
+}
+
+func (tm *tplMap) Delete(filename string) error {
+	tm.mu.Lock()
+	defer tm.mu.Unlock()
+	delete(tm.templates, filename)
+	return nil
+}
+
+func (tm *tplMap) Fetch(filename string) error {
+	tm.mu.Lock()
+	defer tm.mu.Unlock()
+	return tm.fetchLocked(filename)
+}
+
+func (tm *tplMap) fetchLocked(filename string) error {
+	tpl, err := raymond.ParseFile("templates/" + filename)
+	if err != nil {
+		return fmt.Errorf("could not parse template %q: %w", filename, err)
+	}
+	tpl.RegisterHelper("datetime", func(timeStr string) string {
+		timestamp, err := strconv.ParseInt(timeStr, 10, 64)
+		if err != nil {
+			log.Printf("Could not parse timestamp '%v', falling back to current time", timeStr)
+			timestamp = time.Now().Unix()
+		}
+		return time.Unix(timestamp, 0).Format("Jan 2 2006, 3:04 PM")
+	})
+	tpl.RegisterHelper("rssDatetime", func(timeStr string) string {
+		timestamp, err := strconv.ParseInt(timeStr, 10, 64)
+		if err != nil {
+			log.Printf("Could not parse timestamp '%v', falling back to current time", timeStr)
+			timestamp = time.Now().Unix()
+		}
+		return common.RSSDatetime(timestamp)
+	})
+	tpl.RegisterHelper("getFullUrl", func(slug string) string {
+		return common.BlogURL + "/" + slug
+	})
+	tm.templates[filename] = tpl
+	return nil
+}
diff --git a/watcher/watcher.go b/watcher/watcher.go
new file mode 100644
index 0000000..cb73808
--- /dev/null
+++ b/watcher/watcher.go
@@ -0,0 +1,119 @@
+package watcher
+
+import (
+	"iter"
+	"log"
+	"os"
+	"runtime"
+	"strings"
+
+	"github.com/rjeczalik/notify"
+)
+
+type WatchEventKind int
+
+const (
+	Update WatchEventKind = iota
+	Clean
+)
+
+// WatchEvent represents a change to a watched folder. It notes which file
+// changed and what change happened to it.
+type WatchEvent struct {
+	File string
+	Kind WatchEventKind
+}
+
+func updated(f string) *WatchEvent {
+	return &WatchEvent{
+		File: f,
+		Kind: Update,
+	}
+}
+
+func cleaned(f string) *WatchEvent {
+	return &WatchEvent{
+		File: f,
+		Kind: Clean,
+	}
+}
+
+// Watcher classifies notify.Events into updates and deletes, and calls the
+// respective functions for a file when those events happen to that file.
+func Watch(folder string, up Updater[string]) {
+	cwd, err := os.Getwd()
+	if err != nil {
+		log.Fatal("could not get current working directory for listener!")
+	}
+	cwd = cwd + "/"
+
+	c := make(chan notify.EventInfo, 1)
+
+	var events []notify.Event
+
+	// inotify events prevent double-firing of
+	// certain events in Linux.
+	if runtime.GOOS == "linux" {
+		events = []notify.Event{
+			notify.InCloseWrite,
+			notify.InMovedFrom,
+			notify.InMovedTo,
+			notify.InDelete,
+		}
+	} else {
+		events = []notify.Event{
+			notify.Create,
+			notify.Remove,
+			notify.Rename,
+			notify.Write,
+		}
+	}
+
+	err = notify.Watch(folder, c, events...)
+
+	if err != nil {
+		log.Fatalf("could not setup watcher for folder %s: %s", folder, err)
+	}
+
+	defer notify.Stop(c)
+	for {
+		ei := <-c
+		log.Printf("event: %s", ei.Event())
+		switch ei.Event() {
+		case notify.InCloseWrite, notify.InMovedTo, notify.Create, notify.Rename, notify.Write:
+			filePath := strings.TrimPrefix(ei.Path(), cwd)
+			log.Printf("updating file %s", filePath)
+			err := up.Fetch(strings.TrimPrefix(filePath, folder))
+			if err != nil {
+				log.Printf("up.Fetch(%q): %v", filePath, err)
+			}
+		case notify.InMovedFrom, notify.InDelete, notify.Remove:
+			filePath := strings.TrimPrefix(ei.Path(), cwd)
+			log.Printf("cleaning file %s", filePath)
+			err := up.Delete(strings.TrimPrefix(filePath, folder))
+			if err != nil {
+				log.Printf("up.Delete(%q): %v", filePath, err)
+			}
+		}
+	}
+}
+
+// Updater is a key-value store which can be informed when to recompute values
+// for a particular key. Updaters are normally also AutoMaps.
+type Updater[K any] interface {
+	Fetch(K) error
+	Delete(K) error
+}
+
+// AutoMap is a key-value store where the values are automatically computed by
+// the store itself, based on the key.
+type AutoMap[K, V any] interface {
+	Get(K) (V, bool)
+}
+
+// OrderedAutoMap is an AutoMap that provides an iterator over its
+// currently-existing keys in a known order.
+type OrderedAutoMap[K, V any] interface {
+	AutoMap[K, V]
+	All() iter.Seq2[K, V]
+}