diff --git a/maud_macros/src/parse.rs b/maud_macros/src/parse.rs
index 246de31..156025e 100644
--- a/maud_macros/src/parse.rs
+++ b/maud_macros/src/parse.rs
@@ -156,6 +156,11 @@ impl<'cx, 'a, 'i> Parser<'cx, 'a, 'i> {
                 self.shift(2);
                 self.match_expr(sp)?;
             },
+            // Let
+            [at!(), keyword!(sp, k), ..] if k.is_keyword(keywords::Let) => {
+                self.shift(2);
+                self.let_expr(sp)?;
+            }
             // Element
             [ident!(sp, _), ..] => {
                 let name = self.namespaced_name().unwrap();
@@ -402,6 +407,42 @@ impl<'cx, 'a, 'i> Parser<'cx, 'a, 'i> {
         Ok(body)
     }
 
+    /// Parses and renders a `@let` expression.
+    ///
+    /// The leading `@let` should already be consumed.
+    fn let_expr(&mut self, sp: Span) -> PResult<()> {
+        let mut pattern = vec![];
+        loop { match *self.input {
+            [eq!(), ..] => {
+                self.shift(1);
+                break;
+            },
+            [ref tt, ..] => {
+                self.shift(1);
+                pattern.push(tt.clone());
+            },
+            _ => parse_error!(self, sp, "invalid @let"),
+        }}
+        let pattern = self.with_rust_parser(pattern, RustParser::parse_pat)?;
+        let mut rhs = vec![];
+        let body;
+        loop { match *self.input {
+            [TokenTree::Delimited(sp, ref d), ..] if d.delim == DelimToken::Brace => {
+                self.shift(1);
+                body = self.block(sp, &d.tts)?;
+                break;
+            },
+            [ref tt, ..] => {
+                self.shift(1);
+                rhs.push(tt.clone());
+            },
+            _ => parse_error!(self, sp, "invalid @let"),
+        }}
+        let rhs = self.with_rust_parser(rhs, RustParser::parse_expr)?;
+        self.render.emit_let(pattern, rhs, body);
+        Ok(())
+    }
+
     /// Parses and renders an element node.
     ///
     /// The element name should already be consumed.
diff --git a/maud_macros/src/render.rs b/maud_macros/src/render.rs
index f1b034f..10222da 100644
--- a/maud_macros/src/render.rs
+++ b/maud_macros/src/render.rs
@@ -156,6 +156,11 @@ impl<'cx, 'a> Renderer<'cx, 'a> {
         let stmt = quote_stmt!(self.cx, match $match_var { $match_body }).unwrap();
         self.push(stmt);
     }
+
+    pub fn emit_let(&mut self, pattern: P<Pat>, rhs: P<Expr>, body: Vec<Stmt>) {
+        let stmt = quote_stmt!(self.cx, { let $pattern = $rhs; $body }).unwrap();
+        self.push(stmt);
+    }
 }
 
 fn html_escape(s: &str) -> String {
diff --git a/maud_macros/tests/control_structures.rs b/maud_macros/tests/control_structures.rs
index 42c40b3..7d574f5 100644
--- a/maud_macros/tests/control_structures.rs
+++ b/maud_macros/tests/control_structures.rs
@@ -131,3 +131,27 @@ fn match_in_attribute() {
         assert_eq!(s, output);
     }
 }
+
+#[test]
+fn let_expr() {
+    let s = html! {
+        @let x = 42 {
+            "I have " (x) " cupcakes!"
+        }
+    }.into_string();
+    assert_eq!(s, "I have 42 cupcakes!");
+}
+
+#[test]
+fn let_lexical_scope() {
+    let x = 42;
+    let s = html! {
+        @let x = 99 {
+            "Twilight thought I had " (x) " cupcakes, "
+        }
+        "but I only had " (x) "."
+    }.into_string();
+    assert_eq!(s, concat!(
+            "Twilight thought I had 99 cupcakes, ",
+            "but I only had 42."));
+}