Tutorial: build a small site
A layout, a blog, a Markdown page, a form and a 404, one file at a time.
In this tutorial you build Field Notes, a small site with a shared layout, a blog with a dynamic route, a Markdown page, a contact form that uses a session, a custom 404 page and Sass styling. Then you check it and see what deploying involves.
You need the crisptastic executable from the Quickstart.
Every file below was compiled and rendered with the real engine while this
guide was written.
1. Create the folder
mkdir field-notes && cd field-notes
mkdir _partials blog contact assets
Everything below is a file inside field-notes/.
2. A shared layout
Layouts are a pair of partials: one that opens the page and one that closes
it. File names beginning with _ are never served as pages.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Field Notes</title>
<link rel="stylesheet" href="/style.css">
</head>
<body>
<header>
<a href="/">Field Notes</a>
<nav><a href="/blog">Blog</a> <a href="/about">About</a> <a href="/contact">Contact</a></nav>
</header>
<main>
</main>
<footer>© Field Notes</footer>
</body>
</html>
3. Home page
@include("_partials/_layout-open.html.crisp")
<h1>Field Notes</h1>
<p>Short notes from the field. Start with the <a href="/blog">blog</a>.</p>
@include("_partials/_layout-close.html.crisp")
Start the development server in another terminal and open http://127.0.0.1:6174/:
crisptastic dev
Leave it running. Every change you save from here on is recompiled and the browser reloads.
4. Styling
$ink: #16201e;
body {
font-family: system-ui, sans-serif;
color: $ink;
max-width: 40rem;
margin: 2rem auto;
}
nav a { margin-left: 1rem; }
The page links to /style.css. There is no such file: the server compiles
assets/style.scss on demand. Note that the file assets/style.scss is served
at /style.css, with no /assets/ in the URL.
5. Blog: data in a partial, a list and a dynamic route
Real sites get posts from an API (see Fetching and shaping data). Here a partial plays the part of the API so you can see how data flows to pages.
{{-- Stand-in for an API: in a real site, use @fetch here instead. --}}
@shape posts = [{ slug: "hello-world", title: "Hello, world", body: "The first post." }, { slug: "second-post", title: "A second post", body: "More to say." }] with 'A'
@shape binds a name, and a partial runs in the same scope as the page that
includes it, so any page that includes this partial can use posts. (@shape
must stay on a single line.)
@include("../_partials/_layout-open.html.crisp")
@include("../_partials/_posts.html.crisp")
<h1>Blog</h1>
@for (post in posts) {
<p><a href="/blog/{{ post.slug }}">{{ post.title }}</a></p>
}
@include("../_partials/_layout-close.html.crisp")
A file named in square brackets is a dynamic segment. The part of the URL
that matches is available as request.route.params.
@include("../_partials/_layout-open.html.crisp")
@include("../_partials/_posts.html.crisp")
@for (post in posts) {
@if (post.slug = request.route.params.slug) {
<article>
<h1>{{ post.title }}</h1>
<p>{{ post.body }}</p>
</article>
}
}
@include("../_partials/_layout-close.html.crisp")
Open http://127.0.0.1:6174/blog/second-post. Notes:
- Equality is a single
=:post.slug = request.route.params.slug. {{ post.title }}is HTML-escaped automatically.- An unknown slug (
/blog/nope) renders a page with an empty body. A real site fetches one post from an API, and when the fetch is notokit sets@status 404and shows a message.
6. A Markdown page
# About Field Notes
A small site built with **Crisptastic**. You are reading the page at {{ request.path }}.
- Pages are files
- Partials start with an underscore
@include("_partials/_layout-open.html.crisp")
@include("_partials/_about.md.crisp")
@include("_partials/_layout-close.html.crisp")
An HTML page can include a Markdown partial. (Inside such a partial, keep
{{ }} out of code spans and link targets; see
Pages and templating.)
7. A form that uses a session
@if (request.method = "POST") {
@session contact_name = request.body.name
@session contact_message = request.body.message
@redirect "/contact/thanks"
}
@include("../_partials/_layout-open.html.crisp")
<h1>Contact</h1>
<form method="post">
<input type="hidden" name="_csrf" value="{{ context.csrf.token }}">
<p><label>Name <input name="name" required></label></p>
<p><label>Message <textarea name="message" required></textarea></label></p>
<button>Send</button>
</form>
@include("../_partials/_layout-close.html.crisp")
@include("../_partials/_layout-open.html.crisp")
@if (context.session.contact_name) {
<h1>Thanks, {{ context.session.contact_name }}</h1>
} @else {
<h1>Nothing to see here</h1>
<p><a href="/contact">Back to the form</a></p>
}
@include("../_partials/_layout-close.html.crisp")
What happens on a submit:
- The browser
POSTs to/contact. The same page handles it. @sessionstores the values on the server. On the firstPOSTthere is no session yet, so the server creates one and sets its cookie.@redirectsends the browser to/contact/thankswith a303, so refreshing does not resubmit the form. The thanks page reads the session.
Two production rules shaped this design. State is written only in the POST
branch, because a production host rejects session and cookie changes during a
GET. And the hidden _csrf field is present because, once a visitor has a
session, any POST that lacks the token is rejected, in dev too. The field is
empty on a visitor's first visit (no session yet), so that first POST is not
checked.
The messages go nowhere: this tutorial stores them only in the visitor's own
session. To deliver them you would @call mail.send(...) (experimental) or
@fetch an API with fetch_json(post, ...); see
Capabilities and integrations.
8. A custom 404 page
@include("_partials/_layout-open.html.crisp")
<h1>Not found</h1>
<p>Nothing lives at <code>{{ request.path }}</code>.</p>
@include("_partials/_layout-close.html.crisp")
The file must be at the top level, named 404. Try
http://127.0.0.1:6174/nope.
9. Check it
Stop the server (Ctrl+C) and run:
crisptastic check .
crisptastic routes .
ok: 6 routes, 4 partials
/
/about
/blog
/blog/:slug
/contact
/contact/thanks
Six routes; the four partials (two layout files, _posts and _about) and the
404 handler are not listed as routes.
10. Deploying it
The site needs crisptastic.toml with its domains:
[deployment]
domains = ["notes.example.com"]
Then follow Deploying a site: create keys, run
crisptastic deploy, publish the Capsule and crisptastic notify the host. The
host's operator (Running a host) must have your signing
public key and the host's recipient must be on your --recipient list.
What next
- Add
@fetchto read from your real API: Fetching and shaping data. - Sign users in or take payments: Capabilities and integrations.
- Understand what differs between your laptop and production: Sessions and forms and Security model.