diff --git a/Cargo.toml b/Cargo.toml
index 747739e..8f95f1a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,7 +18,7 @@ serde_derive = "1.0.219"
 serde_json = "1.0.140"
 syntect = "5.2.0"
 tera = { version = "1.20.0", features = ["builtins"] }
-time = { version = "0.3.40", features = ["serde"] }
+time = { version = "0.3.40", features = ["serde", "macros"] }
 tokio = { version = "1.44.1", features = ["full", "tracing"] }
 toml = "0.8.20"
 tower = { version = "0.5.2", features = ["full"] }
diff --git a/compose.yaml b/compose.yaml
index 0843626..3a2cd4f 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -8,8 +8,7 @@ services:
     depends_on:
       - otel-collector
     environment:
-      TLX_OTLP_ENABLED: true
-      TLX_LOG: debug
+      TLX_LOGGING: debug
   otel-collector:
     image: otel/opentelemetry-collector:latest
     restart: unless-stopped
diff --git a/config.toml b/config.toml
index 3dcebd9..9acde7b 100644
--- a/config.toml
+++ b/config.toml
@@ -1,7 +1,7 @@
 title = "tollyx.se"
 base_url = "http://localhost:8080/"
 bind_address = "0.0.0.0:8080"
-logging = "website=debug"
-log_format = "compact"
+logging = "website=debug,warn"
+log_format = "pretty"
 drafts = true
 watch = true
diff --git a/fly.toml b/fly.toml
index 9aadf2a..14ce8a8 100644
--- a/fly.toml
+++ b/fly.toml
@@ -7,12 +7,12 @@ app = "cool-glade-6208"
 primary_region = "arn"
 
 [http_service]
-  internal_port = 8080
-  force_https = true
-  auto_stop_machines = true
-  auto_start_machines = true
-  min_machines_running = 0
+internal_port = 8080
+force_https = true
+auto_stop_machines = "stop"
+auto_start_machines = true
+min_machines_running = 0
 
 [metrics]
-  port = 8180
-  path = "/metrics"
+port = 8180
+path = "/metrics"
diff --git a/pages/index.md b/pages/index.md
index 07796a5..b5767e6 100644
--- a/pages/index.md
+++ b/pages/index.md
@@ -29,3 +29,5 @@ anyway here's a new todo list:
 - [ ] other pages (now I've got it set up so I can write any page in markdown!!!)
 - [ ] graphviz (or something else) to svg rendering (want it to be serverside)
 - [ ] image processing (resizing, conversion to jpgxl, avif, others?)
+- [ ] Obsidian-style wiki-links
+    - [ ] YAML-frontmatter for even more obsidian compat (YAML is a pain, though...)
diff --git a/pages/posts/draft-test-copy-2.md b/pages/posts/draft-test-copy-2.md
new file mode 100644
index 0000000..a6976cb
--- /dev/null
+++ b/pages/posts/draft-test-copy-2.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy 2"
+draft = true
+date = 2025-04-02T20:59:21+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/pages/posts/draft-test-copy-3.md b/pages/posts/draft-test-copy-3.md
new file mode 100644
index 0000000..5a80d9d
--- /dev/null
+++ b/pages/posts/draft-test-copy-3.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy 3"
+draft = true
+date = 2025-04-02T20:59:28+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/pages/posts/draft-test-copy-4.md b/pages/posts/draft-test-copy-4.md
new file mode 100644
index 0000000..2c617f2
--- /dev/null
+++ b/pages/posts/draft-test-copy-4.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy 4"
+draft = true
+date = 2025-04-02T20:59:31+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/pages/posts/draft-test-copy-5.md b/pages/posts/draft-test-copy-5.md
new file mode 100644
index 0000000..f4a7e7f
--- /dev/null
+++ b/pages/posts/draft-test-copy-5.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy 5"
+draft = true
+date = 2025-04-02T20:59:35+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/pages/posts/draft-test-copy-6.md b/pages/posts/draft-test-copy-6.md
new file mode 100644
index 0000000..35e0ac3
--- /dev/null
+++ b/pages/posts/draft-test-copy-6.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy 6"
+draft = true
+date = 2025-04-02T20:59:38+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/pages/posts/draft-test-copy-7.md b/pages/posts/draft-test-copy-7.md
new file mode 100644
index 0000000..fbdae9c
--- /dev/null
+++ b/pages/posts/draft-test-copy-7.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy 7"
+draft = true
+date = 2025-04-02T20:59:42+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/pages/posts/draft-test-copy.md b/pages/posts/draft-test-copy.md
new file mode 100644
index 0000000..4507081
--- /dev/null
+++ b/pages/posts/draft-test-copy.md
@@ -0,0 +1,7 @@
++++
+title = "draft test copy"
+draft = true
+date = 2025-04-02T20:59:17+02:00
++++
+
+wow look it's a hidden post because it's marked as a draft
diff --git a/src/feed.rs b/src/feed.rs
index e48197e..30fc1e9 100644
--- a/src/feed.rs
+++ b/src/feed.rs
@@ -8,48 +8,91 @@ use crate::{AppState, page::Page, tag::Tag};
 
 #[derive(Serialize, Debug)]
 struct FeedContext<'a> {
-    feed_url: &'a str,
-    base_url: &'a str,
-    next_url: Option<&'a str>,
-    previous_url: Option<&'a str>,
-    first_url: Option<&'a str>,
-    last_url: Option<&'a str>,
+    feed_url: String,
+    base_url: String,
+    next_url: Option<String>,
+    previous_url: Option<String>,
+    first_url: Option<String>,
+    last_url: Option<String>,
     site_title: &'a str,
-    last_updated: &'a str,
+    last_updated: String,
     tag: Option<&'a Tag>,
-    posts: &'a [&'a Page],
+    posts: Vec<&'a Page>,
+}
+
+impl FeedContext<'_> {
+    fn new<'a>(state: &'a AppState, tag: Option<&'a Tag>, page: usize) -> FeedContext<'a> {
+        let page = page.max(1);
+
+        let mut posts: Vec<&_> = if let Some(tag) = tag {
+            state
+                .published_pages()
+                .filter(|p| p.tags.contains(&tag.slug))
+                .collect()
+        } else {
+            state.published_pages().collect()
+        };
+
+        posts.sort_by_key(|p| p.last_modified());
+        posts.reverse();
+
+        let page_count = posts.chunks(10).count();
+        let posts = posts.chunks(10).nth(page - 1).unwrap_or_default().to_vec();
+
+        let updated = posts
+            .iter()
+            .filter_map(|p| p.last_modified())
+            .max()
+            .unwrap_or(state.startup_time);
+
+        let base_feed_url = if let Some(tag) = tag {
+            format!("{}tags/{}/atom.xml", state.base_url, &tag.slug)
+        } else {
+            format!("{}atom.xml", state.base_url)
+        };
+
+        let (first_url, last_url) = if page_count > 1 {
+            let first_url = base_feed_url.clone();
+            let last_url = format!("{base_feed_url}?page={page_count}");
+
+            (Some(first_url), Some(last_url))
+        } else {
+            (None, None)
+        };
+
+        let next_url = if page < page_count {
+            Some(format!("{}?page={}", base_feed_url, page + 1))
+        } else {
+            None
+        };
+
+        let (feed_url, previous_url) = if page > 1 {
+            (
+                format!("{base_feed_url}?page={page}"),
+                Some(format!("{}?page={}", base_feed_url, page - 1)),
+            )
+        } else {
+            (base_feed_url.clone(), None)
+        };
+
+        FeedContext {
+            feed_url,
+            base_url: state.base_url.to_string(),
+            next_url,
+            previous_url,
+            first_url,
+            last_url,
+            site_title: &state.settings.title,
+            last_updated: updated.format(&Rfc3339).unwrap(),
+            tag,
+            posts,
+        }
+    }
 }
 
 #[instrument(skip(state))]
-pub fn render_atom_feed(state: &AppState) -> Result<String> {
-    let mut posts: Vec<_> = state
-        .pages
-        .values()
-        .filter(|p| p.date.is_some() && p.is_published())
-        .collect();
-
-    posts.sort_by_key(|p| p.last_modified());
-    posts.reverse();
-    posts.truncate(10);
-
-    let updated = posts
-        .iter()
-        .map(|p| p.last_modified())
-        .max()
-        .flatten()
-        .unwrap();
-    let feed = FeedContext {
-        feed_url: &format!("{}atom.xml", state.base_url),
-        base_url: &state.base_url.to_string(),
-        next_url: None,
-        previous_url: None,
-        first_url: None,
-        last_url: None,
-        site_title: &state.settings.title,
-        last_updated: &updated.format(&Rfc3339).unwrap(),
-        tag: None,
-        posts: &posts,
-    };
+pub fn render_atom_feed(state: &AppState, page: usize) -> Result<String> {
+    let feed = FeedContext::new(state, None, page);
 
     let ctx = tera::Context::from_serialize(feed)?;
 
@@ -57,66 +100,33 @@ pub fn render_atom_feed(state: &AppState) -> Result<String> {
 }
 
 #[instrument(skip(tag, state))]
-pub fn render_atom_tag_feed(tag: &Tag, state: &AppState) -> Result<String> {
-    let mut posts: Vec<_> = state
-        .pages
-        .values()
-        .filter(|p| p.is_published() && p.tags.contains(&tag.slug))
-        .collect();
-
-    posts.sort_by_key(|p| &p.date);
-    posts.reverse();
-    posts.truncate(10);
-
-    let updated = posts.iter().map(|p| p.last_modified()).max().flatten();
-    let slug = &tag.slug;
-    let feed = FeedContext {
-        feed_url: &format!("{}tags/{}/atom.xml", state.base_url, slug),
-        base_url: &state.base_url.to_string(),
-        next_url: None,
-        previous_url: None,
-        first_url: None,
-        last_url: None,
-        site_title: &state.settings.title,
-        last_updated: &updated.map_or_else(String::default, |d| d.format(&Rfc3339).unwrap()),
-        tag: Some(tag),
-        posts: &posts,
-    };
+pub fn render_atom_tag_feed(tag: &Tag, state: &AppState, page: usize) -> Result<String> {
+    let feed = FeedContext::new(state, Some(tag), page);
 
     let ctx = tera::Context::from_serialize(feed)?;
 
     Ok(state.tera.render("atom.xml", &ctx)?)
 }
 
-struct _JsonFeed<'a> {
-    version: &'a str,
-    title: &'a str,
-    home_page_url: &'a str,
-    feed_url: &'a str,
-    items: Vec<_JsonFeedItem<'a>>,
-}
-
-struct _JsonFeedItem<'a> {
-    id: &'a str,
-}
-
 #[cfg(test)]
 mod tests {
-    use crate::{AppState, settings::Settings};
+    use crate::AppState;
 
     #[test]
     fn render_atom_feed() {
-        let state = AppState::load(Settings::test_config()).unwrap();
+        let state = AppState::load_test_state();
 
-        super::render_atom_feed(&state).unwrap();
+        super::render_atom_feed(&state, 1).unwrap();
+        super::render_atom_feed(&state, 2).unwrap();
     }
 
     #[test]
     fn render_atom_tag_feeds() {
-        let state = AppState::load(Settings::test_config()).unwrap();
+        let state = AppState::load_test_state();
 
         for tag in state.tags.values() {
-            super::render_atom_tag_feed(tag, &state).unwrap();
+            super::render_atom_tag_feed(tag, &state, 1).unwrap();
+            super::render_atom_tag_feed(tag, &state, 2).unwrap();
         }
     }
 }
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
index 9c86af2..5ef93ce 100644
--- a/src/handlers/mod.rs
+++ b/src/handlers/mod.rs
@@ -6,7 +6,7 @@ use axum::{
     routing::get,
 };
 use std::sync::Arc;
-use time::{OffsetDateTime, format_description::well_known::Rfc2822};
+use time::{OffsetDateTime, format_description::well_known::Rfc2822, macros::format_description};
 use tokio::sync::RwLock;
 use tower_http::services::ServeDir;
 use tracing::log::error;
@@ -16,6 +16,10 @@ use crate::{AppState, error::WebsiteError};
 pub mod pages;
 pub mod tags;
 
+const LAST_MODIFIED_FORMAT: &[time::format_description::BorrowedFormatItem<'_>] = format_description!(
+    "[weekday repr:short], [day] [month repr:short] [year] [hour]:[minute]:[second] GMT"
+);
+
 pub fn routes() -> Router<Arc<RwLock<AppState>>> {
     Router::new()
         .merge(pages::router())
@@ -29,20 +33,23 @@ fn should_return_304(
     headers: &HeaderMap,
     last_changed: Option<OffsetDateTime>,
 ) -> Option<Response> {
-    let date = last_changed?;
     let since = headers.get(header::IF_MODIFIED_SINCE)?;
 
     let Ok(parsed) = OffsetDateTime::parse(since.to_str().unwrap(), &Rfc2822) else {
         return None;
     };
 
-    if date >= parsed {
+    if last_changed? >= parsed {
         Some(Response::builder().status(304).body(Body::empty()).unwrap())
     } else {
         None
     }
 }
 
+fn format_last_modified(datetime: OffsetDateTime) -> String {
+    datetime.to_utc().format(&LAST_MODIFIED_FORMAT).unwrap()
+}
+
 impl IntoResponse for WebsiteError {
     fn into_response(self) -> Response {
         match self {
@@ -64,7 +71,7 @@ impl IntoResponse for WebsiteError {
 
 #[cfg(test)]
 mod tests {
-    use std::{path::PathBuf, sync::Arc};
+    use std::sync::Arc;
 
     use tokio::sync::RwLock;
 
@@ -72,18 +79,9 @@ mod tests {
 
     #[tokio::test]
     async fn setup_routes() {
-        let mut state = AppState {
-            base_url: "http://localhost:8180".parse().unwrap(),
-            tera: tera::Tera::new("templates/**/*").unwrap(),
-            ..Default::default()
-        };
         // Load the actual posts, just to make this test fail if
         // aliases overlap with themselves or other routes
-        let root = PathBuf::from("pages/");
-        let posts = crate::page::load_recursive(&state, &root, &root, None).unwrap();
-        state.tags = crate::tag::get_tags(posts.values());
-        state.pages = posts;
-        let state = Arc::new(RwLock::new(state));
+        let state = Arc::new(RwLock::new(AppState::load_test_state()));
 
         super::routes().with_state(state).into_make_service();
     }
diff --git a/src/handlers/pages.rs b/src/handlers/pages.rs
index 7f8a6c6..127a77b 100644
--- a/src/handlers/pages.rs
+++ b/src/handlers/pages.rs
@@ -4,21 +4,22 @@ use anyhow::anyhow;
 use axum::{
     Router,
     body::Body,
-    extract::{OriginalUri, Request, State},
+    extract::{OriginalUri, Query, Request, State},
     http::{self, HeaderMap, StatusCode, Uri, header},
     response::{Html, IntoResponse, Redirect, Response},
     routing::get,
 };
 
-use time::format_description::well_known::Rfc3339;
+use serde::Deserialize;
 use tokio::sync::RwLock;
 use tower::ServiceExt;
 use tower_http::services::ServeDir;
-use tracing::instrument;
+use tracing::{info, instrument};
 
 use crate::{
     AppState,
     error::WebsiteError,
+    handlers::format_last_modified,
     page::{Page, render_page},
 };
 
@@ -93,13 +94,21 @@ async fn view(
         .into_response())
 }
 
-#[instrument(skip(state))]
+#[derive(Deserialize)]
+struct Pagination {
+    page: Option<usize>,
+}
+
+#[instrument(skip(state, pagination))]
 pub async fn feed(
     State(state): State<Arc<RwLock<AppState>>>,
     headers: HeaderMap,
+    Query(pagination): Query<Pagination>,
 ) -> Result<Response, WebsiteError> {
     let state = state.read().await;
-    let mut posts: Vec<&Page> = state.pages.values().filter(|p| p.is_published()).collect();
+    let page = pagination.page.unwrap_or(1).max(1);
+
+    let mut posts: Vec<&Page> = state.published_pages().collect();
 
     let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
 
@@ -107,13 +116,26 @@ pub async fn feed(
         return Ok(res);
     }
 
-    posts.sort_by_key(|p| &p.date);
+    posts.sort_by_key(|p| p.last_modified());
     posts.reverse();
-    posts.truncate(10);
+
+    let total = posts.len() / 10 + 1;
+    if page > total {
+        return Ok(WebsiteError::NotFound.into_response());
+    }
+
+    let start = 10 * (page - 1);
+    let end = (start + 10).min(posts.len()) as usize;
+
+    info!("start: {start}, end: {end}, total: {total}");
+
+    if posts.is_empty() {
+        return Ok(WebsiteError::NotFound.into_response());
+    }
 
     let last_modified = last_changed.map_or_else(
-        || state.startup_time.format(&Rfc3339).unwrap(),
-        |d| d.format(&Rfc3339).unwrap(),
+        || format_last_modified(state.startup_time),
+        format_last_modified,
     );
 
     Ok((
@@ -122,7 +144,7 @@ pub async fn feed(
             (header::CONTENT_TYPE, "application/atom+xml"),
             (header::LAST_MODIFIED, &last_modified),
         ],
-        crate::feed::render_atom_feed(&state)?,
+        crate::feed::render_atom_feed(&state, page)?,
     )
         .into_response())
 }
diff --git a/src/handlers/tags.rs b/src/handlers/tags.rs
index af06a9f..e924d39 100644
--- a/src/handlers/tags.rs
+++ b/src/handlers/tags.rs
@@ -2,20 +2,20 @@ use std::sync::Arc;
 
 use axum::{
     Router,
-    extract::{Path, State},
+    extract::{Path, Query, State},
     http::{HeaderMap, StatusCode, header},
     response::{Html, IntoResponse, Redirect, Response},
     routing::get,
 };
 
+use serde::Deserialize;
 use serde_derive::Serialize;
-use time::format_description::well_known::Rfc3339;
 use tokio::sync::RwLock;
 use tracing::instrument;
 
 use crate::{AppState, error::WebsiteError, page::Page};
 
-use super::should_return_304;
+use super::{format_last_modified, should_return_304};
 
 pub fn router() -> Router<Arc<RwLock<AppState>>> {
     Router::new()
@@ -47,7 +47,7 @@ pub async fn index(State(state): State<Arc<RwLock<AppState>>>) -> Result<Respons
         StatusCode::OK,
         [(
             header::LAST_MODIFIED,
-            state.startup_time.format(&Rfc3339).unwrap(),
+            format_last_modified(state.startup_time),
         )],
         Html(res),
     )
@@ -62,9 +62,8 @@ pub async fn view(
 ) -> Result<Response, WebsiteError> {
     let state = state.read().await;
     let mut posts: Vec<&Page> = state
-        .pages
-        .values()
-        .filter(|p| p.is_published() && p.tags.contains(&tag))
+        .published_pages()
+        .filter(|p| p.tags.contains(&tag))
         .collect();
 
     let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
@@ -94,8 +93,8 @@ pub async fn view(
         [(
             header::LAST_MODIFIED,
             &last_changed.map_or_else(
-                || state.startup_time.format(&Rfc3339).unwrap(),
-                |d| d.format(&Rfc3339).unwrap(),
+                || format_last_modified(state.startup_time),
+                format_last_modified,
             ),
         )],
         Html(res),
@@ -103,19 +102,25 @@ pub async fn view(
         .into_response())
 }
 
-#[instrument(skip(state))]
+#[derive(Deserialize)]
+struct Pagination {
+    page: Option<usize>,
+}
+
+#[instrument(skip(state, pagination))]
 pub async fn feed(
     Path(slug): Path<String>,
     State(state): State<Arc<RwLock<AppState>>>,
+    Query(pagination): Query<Pagination>,
     headers: HeaderMap,
 ) -> Result<Response, WebsiteError> {
     let state = state.read().await;
     let tag = state.tags.get(&slug).ok_or(WebsiteError::NotFound)?;
+    let page = pagination.page.unwrap_or(1).max(1);
 
     let mut posts: Vec<&Page> = state
-        .pages
-        .values()
-        .filter(|p| p.is_published() && p.tags.contains(&slug))
+        .published_pages()
+        .filter(|p| p.tags.contains(&slug))
         .collect();
 
     let last_changed = posts.iter().filter_map(|p| p.last_modified()).max();
@@ -135,12 +140,12 @@ pub async fn feed(
             (
                 header::LAST_MODIFIED,
                 &last_changed.map_or_else(
-                    || state.startup_time.format(&Rfc3339).unwrap(),
-                    |d| d.format(&Rfc3339).unwrap(),
+                    || format_last_modified(state.startup_time),
+                    format_last_modified,
                 ),
             ),
         ],
-        crate::feed::render_atom_tag_feed(tag, &state)?,
+        crate::feed::render_atom_tag_feed(tag, &state, page)?,
     )
         .into_response())
 }
diff --git a/src/main.rs b/src/main.rs
index 142f238..59652a9 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,19 +1,27 @@
 #![warn(clippy::pedantic)]
 use anyhow::Result;
 use notify::Watcher;
-use std::{path::Path, sync::Arc};
-use tokio::{net::TcpListener, signal, sync::RwLock};
+use std::{
+    path::Path,
+    sync::Arc,
+    time::{Duration, Instant},
+};
+use time::OffsetDateTime;
+use tokio::{
+    net::TcpListener,
+    signal,
+    sync::{RwLock, mpsc::Receiver},
+};
 use tower_http::{compression::CompressionLayer, cors::CorsLayer, trace::TraceLayer};
-use tracing::{debug, error, instrument, log::info};
+use tracing::{debug, error, instrument, level_filters::LevelFilter, log::info, warn};
 use tracing_subscriber::EnvFilter;
 
 mod error;
 mod feed;
 mod handlers;
 mod helpers;
-mod hilighting;
-mod markdown;
 mod page;
+mod rendering;
 mod settings;
 mod state;
 mod tag;
@@ -43,38 +51,22 @@ async fn main() -> Result<()> {
 }
 
 fn setup_tracing(cfg: &Settings) {
+    let env = std::env::var("RUST_LOG");
+    let filter = EnvFilter::builder()
+        .with_default_directive(LevelFilter::WARN.into())
+        .parse_lossy(if let Ok(log) = env.as_deref() {
+            log
+        } else {
+            &cfg.logging
+        });
+
+    let subs = tracing_subscriber::fmt().with_env_filter(filter);
+
     match cfg.log_format.as_str() {
-        "pretty" => tracing_subscriber::fmt()
-            .pretty()
-            .with_env_filter(
-                EnvFilter::builder()
-                    .with_default_directive(cfg.logging.parse().unwrap_or_default())
-                    .from_env_lossy(),
-            )
-            .init(),
-        "compact" => tracing_subscriber::fmt()
-            .compact()
-            .with_env_filter(
-                EnvFilter::builder()
-                    .with_default_directive(cfg.logging.parse().unwrap_or_default())
-                    .from_env_lossy(),
-            )
-            .init(),
-        "json" => tracing_subscriber::fmt()
-            .json()
-            .with_env_filter(
-                EnvFilter::builder()
-                    .with_default_directive(cfg.logging.parse().unwrap_or_default())
-                    .from_env_lossy(),
-            )
-            .init(),
-        _ => tracing_subscriber::fmt()
-            .with_env_filter(
-                EnvFilter::builder()
-                    .with_default_directive(cfg.logging.parse().unwrap_or_default())
-                    .from_env_lossy(),
-            )
-            .init(),
+        "pretty" => subs.pretty().init(),
+        "compact" => subs.compact().init(),
+        "json" => subs.json().init(),
+        _ => subs.init(),
     }
 }
 
@@ -96,13 +88,20 @@ async fn init_app(cfg: Settings) -> Result<axum::routing::Router> {
 }
 
 async fn start_file_watcher(state: Arc<RwLock<AppState>>) {
-    let (page_tx, mut page_rx) = tokio::sync::mpsc::channel::<notify::Event>(1);
+    fn event_filter(event: &notify::Event) -> bool {
+        event.kind.is_modify() || event.kind.is_remove()
+    }
+
+    let (page_tx, page_rx) = tokio::sync::mpsc::channel::<notify::Event>(1);
 
     let mut page_watcher =
         notify::recommended_watcher(move |event: Result<notify::Event, notify::Error>| {
             let Ok(event) = event.inspect_err(|e| error!("File watcher error: {}", e)) else {
                 return;
             };
+            if !event_filter(&event) {
+                return;
+            }
             _ = page_tx
                 .blocking_send(event)
                 .inspect_err(|e| error!("Failed to add watch event to channel: {}", e));
@@ -113,35 +112,16 @@ async fn start_file_watcher(state: Arc<RwLock<AppState>>) {
         .watch(Path::new("pages/"), notify::RecursiveMode::Recursive)
         .expect("add pages dir to watcher");
 
-    let page_fut = async {
-        while let Some(event) = page_rx.recv().await {
-            if !(event.kind.is_create() || event.kind.is_remove() || event.kind.is_modify()) {
-                continue;
-            }
-            if !event.paths.iter().any(|p| p.is_file()) {
-                continue;
-            }
-            debug!("{:?}", event);
-
-            let mut state = state.write().await;
-
-            info!("Reloading pages");
-            let root_path = Path::new("pages/");
-            if let Ok(pages) = page::load_recursive(&state, root_path, root_path, None)
-                .inspect_err(|err| error!("Error reloading pages: {}", err))
-            {
-                state.pages = pages;
-            }
-        }
-    };
-
-    let (template_tx, mut template_rx) = tokio::sync::mpsc::channel::<notify::Event>(1);
+    let (template_tx, template_rx) = tokio::sync::mpsc::channel::<notify::Event>(1);
 
     let mut template_watcher =
         notify::recommended_watcher(move |event: Result<notify::Event, notify::Error>| {
             let Ok(event) = event.inspect_err(|e| error!("File watcher error: {}", e)) else {
                 return;
             };
+            if !event_filter(&event) {
+                return;
+            }
             _ = template_tx
                 .blocking_send(event)
                 .inspect_err(|e| error!("Failed to add watch event to channel: {}", e));
@@ -152,28 +132,61 @@ async fn start_file_watcher(state: Arc<RwLock<AppState>>) {
         .watch(Path::new("templates/"), notify::RecursiveMode::Recursive)
         .expect("add templates dir to watcher");
 
-    let template_fut = async {
-        while let Some(event) = template_rx.recv().await {
-            if !(event.kind.is_create() || event.kind.is_remove() || event.kind.is_modify()) {
-                continue;
-            }
-            if !event.paths.iter().any(|p| p.is_file()) {
-                continue;
-            }
-            debug!("{:?}", event);
+    tokio::join!(
+        page_watch_loop(state.clone(), page_rx),
+        template_watch_loop(state.clone(), template_rx)
+    );
+}
 
-            let mut state = state.write().await;
+const WATCHER_DEBOUNCE_MILLIS: u64 = 100;
 
-            info!("Reloading templates");
-            _ = state
-                .tera
-                .full_reload()
-                .inspect_err(|err| error!("Error reloading templates: {}", err));
+async fn page_watch_loop(state: Arc<RwLock<AppState>>, mut rx: Receiver<notify::Event>) {
+    let mut last_reload = Instant::now();
+    debug!("Now watching pages");
+    while let Some(_event) = rx.recv().await {
+        if last_reload.elapsed() < Duration::from_millis(WATCHER_DEBOUNCE_MILLIS) {
+            continue;
         }
-    };
 
-    info!("file watchers initialized");
-    tokio::join!(page_fut, template_fut);
+        let pages = {
+            let state = state.read().await;
+
+            info!("Reloading pages");
+            let root_path = Path::new("pages/");
+            page::load_all(&state, root_path, root_path)
+                .inspect_err(|err| error!("Error reloading pages: {}", err))
+                .ok()
+        };
+
+        if let Some(pages) = pages {
+            let mut state = state.write().await;
+            state.pages = pages;
+            state.last_modified = OffsetDateTime::now_utc();
+            last_reload = Instant::now();
+        }
+    }
+    warn!("Page watch loop stopped");
+}
+
+async fn template_watch_loop(state: Arc<RwLock<AppState>>, mut rx: Receiver<notify::Event>) {
+    let mut last_reload = Instant::now();
+    debug!("Now watching templates");
+    while let Some(_event) = rx.recv().await {
+        if last_reload.elapsed() < Duration::from_millis(WATCHER_DEBOUNCE_MILLIS) {
+            continue;
+        }
+
+        let mut state = state.write().await;
+
+        info!("Reloading templates");
+        _ = state
+            .tera
+            .full_reload()
+            .inspect_err(|err| error!("Error reloading templates: {}", err));
+        state.last_modified = OffsetDateTime::now_utc();
+        last_reload = Instant::now();
+    }
+    warn!("Template watch loop stopped");
 }
 
 async fn shutdown_signal() {
diff --git a/src/page.rs b/src/page.rs
index ffeb859..7cae0ad 100644
--- a/src/page.rs
+++ b/src/page.rs
@@ -14,7 +14,7 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339};
 
 use tracing::{debug, info, instrument};
 
-use crate::{AppState, error::WebsiteError, helpers, markdown};
+use crate::{AppState, error::WebsiteError, helpers, rendering::markdown};
 
 #[derive(Deserialize, Debug, Default)]
 pub struct FrontMatter {
@@ -116,8 +116,17 @@ impl Page {
     }
 }
 
+#[instrument(skip(state))]
+pub fn load_all(state: &AppState, root: &Path, folder: &Path) -> Result<HashMap<String, Page>> {
+    let pages = load_recursive(state, root, folder, None)?;
+
+    info!("{} pages loaded", pages.len());
+
+    Ok(pages)
+}
+
 #[instrument(skip(state, parent))]
-pub fn load_recursive(
+fn load_recursive(
     state: &AppState,
     root: &Path,
     folder: &Path,
@@ -174,6 +183,7 @@ pub fn load_recursive(
         debug!("{path} has {children} child pages");
         pages.insert(page.absolute_path.clone(), page);
     }
+
     Ok(pages)
 }
 
@@ -199,7 +209,7 @@ pub fn load_page(state: &AppState, path: &Path, root_folder: &Path) -> Result<Op
 
     let base_uri = helpers::uri_with_path(&state.base_url, base_path);
 
-    let content = markdown::render_markdown_to_html(Some(&base_uri), &content);
+    let content = markdown::render_to_html(Some(&base_uri), &content);
     let frontmatter = match content.meta_kind {
         Some(MetadataBlockKind::PlusesStyle) => toml::from_str(&content.metadata)?,
         Some(MetadataBlockKind::YamlStyle) => unimplemented!("YAML frontmatter is not implemented"),
diff --git a/src/hilighting.rs b/src/rendering/hilighting.rs
similarity index 100%
rename from src/hilighting.rs
rename to src/rendering/hilighting.rs
diff --git a/src/markdown.rs b/src/rendering/markdown.rs
similarity index 97%
rename from src/markdown.rs
rename to src/rendering/markdown.rs
index 46ea892..c0abf03 100644
--- a/src/markdown.rs
+++ b/src/rendering/markdown.rs
@@ -1,7 +1,8 @@
 use std::sync::LazyLock;
 
+use super::hilighting;
 use crate::helpers;
-use crate::hilighting;
+
 use axum::http::Uri;
 use pulldown_cmark::CodeBlockKind;
 use pulldown_cmark::Event;
@@ -23,7 +24,7 @@ pub struct RenderResult {
 }
 
 #[instrument(skip(markdown))]
-pub fn render_markdown_to_html(base_uri: Option<&Uri>, markdown: &str) -> RenderResult {
+pub fn render_to_html(base_uri: Option<&Uri>, markdown: &str) -> RenderResult {
     let mut opt = Options::empty();
 
     opt.insert(Options::ENABLE_FOOTNOTES);
diff --git a/src/rendering/mod.rs b/src/rendering/mod.rs
new file mode 100644
index 0000000..7d88361
--- /dev/null
+++ b/src/rendering/mod.rs
@@ -0,0 +1,2 @@
+pub mod hilighting;
+pub mod markdown;
diff --git a/src/state.rs b/src/state.rs
index 70bacf0..f30e692 100644
--- a/src/state.rs
+++ b/src/state.rs
@@ -4,7 +4,6 @@ use anyhow::Result;
 use axum::http::Uri;
 use tera::Tera;
 use time::OffsetDateTime;
-use tracing::info;
 
 use crate::{
     page::{self, Page},
@@ -16,6 +15,7 @@ pub struct AppState {
     pub startup_time: OffsetDateTime,
     pub base_url: Uri,
     pub pages: HashMap<String, Page>,
+    pub last_modified: OffsetDateTime,
     pub aliases: HashMap<String, String>,
     pub settings: Settings,
     pub tags: HashMap<String, Tag>,
@@ -32,6 +32,7 @@ impl Default for AppState {
             aliases: HashMap::default(),
             pages: HashMap::default(),
             tags: HashMap::default(),
+            last_modified: OffsetDateTime::now_utc(),
         }
     }
 }
@@ -49,8 +50,7 @@ impl AppState {
         };
 
         let root_path = Path::new("pages/");
-        let pages = page::load_recursive(&state, root_path, root_path, None)?;
-        info!("{} pages loaded", pages.len());
+        let pages = page::load_all(&state, root_path, root_path)?;
 
         let tags = tag::get_tags(pages.values());
         state.aliases = pages
@@ -62,18 +62,32 @@ impl AppState {
             })
             .collect();
 
+        let last_modified = pages.values().filter_map(Page::last_modified).max();
+
+        state.last_modified = last_modified.unwrap_or(state.last_modified);
         state.pages = pages;
         state.tags = tags;
         Ok(state)
     }
+
+    pub fn published_pages(&self) -> impl Iterator<Item = &Page> {
+        self.pages
+            .values()
+            .filter(|p| p.date.is_some() && p.is_published())
+    }
+
+    #[cfg(test)]
+    pub fn load_test_state() -> AppState {
+        AppState::load(Settings::test_config()).unwrap()
+    }
 }
 
 #[cfg(test)]
 mod tests {
-    use crate::{AppState, settings::Settings};
+    use crate::AppState;
 
     #[test]
     fn appstate_load() {
-        _ = AppState::load(Settings::test_config()).unwrap();
+        _ = AppState::load_test_state();
     }
 }
diff --git a/templates/atom.xml b/templates/atom.xml
index 50d96b0..7f74b59 100644
--- a/templates/atom.xml
+++ b/templates/atom.xml
@@ -28,7 +28,7 @@
         <author>
           <name>tollyx</name>
         </author>
-        <link rel="alternate" href="{{ base_url | trim_end_matches(pat='/') | safe }}{{ post.absolute_path | safe }}" type="text/html"/>
+        <link href="{{ base_url | trim_end_matches(pat='/') | safe }}{{ post.absolute_path | safe }}" type="text/html"/>
         <id>{{ base_url | trim_end_matches(pat='/') | safe }}{{ post.absolute_path | safe }}</id>
         <content type="html">
         {{ post.content }}