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.

_partials/_layout-open.html.crisp
<!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>
_partials/_layout-close.html.crisp
</main>
<footer>&copy; Field Notes</footer>
</body>
</html>

3. Home page

index.html.crisp
@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

assets/style.scss
$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.

_partials/_posts.html.crisp
{{-- 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.)

blog/index.html.crisp
@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.

blog/[slug].html.crisp
@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 not ok it sets @status 404 and shows a message.

6. A Markdown page

_partials/_about.md.crisp
# 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
about.html.crisp
@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

contact/index.html.crisp
@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")
contact/thanks.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:

  1. The browser POSTs to /contact. The same page handles it.
  2. @session stores the values on the server. On the first POST there is no session yet, so the server creates one and sets its cookie.
  3. @redirect sends the browser to /contact/thanks with a 303, 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

404.html.crisp
@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