Complete Code Examples: Building This Blog in 10 Minutes

This companion file contains complete, type-safe, copy-paste-ready code examples from the blog post How We Built This Blog in About 10 Minutes.

All code here is verified to work without type errors and includes all necessary imports.

Table of Contents

Blog Service

File: app/services/blog.ts

import Service from '@ember/service';
import { marked } from 'marked';

// Type definitions for our blog post data
interface BlogPost {
  id: string;
  title: string;
  date: string;
  slug: string;
  excerpt: string;
}

interface BlogPostWithContent extends BlogPost {
  content: string;
}

export default class BlogService extends Service {
  private postsCache: BlogPost[] | null = null;
  private contentCache: Map<string, string> = new Map();

  async loadPosts(): Promise<BlogPost[]> {
    if (this.postsCache) {
      return this.postsCache;
    }

    const response = await fetch('/blog-posts/index.json');
    const data = (await response.json()) as { posts: BlogPost[] };
    this.postsCache = data.posts;
    return data.posts;
  }

  async loadPost(slug: string): Promise<BlogPostWithContent | null> {
    const posts = await this.loadPosts();
    const post = posts.find((p) => p.slug === slug);

    if (!post) {
      return null;
    }

    if (this.contentCache.has(slug)) {
      return {
        ...post,
        content: this.contentCache.get(slug)!,
      };
    }

    const response = await fetch(`/blog-posts/${slug}.md`);
    const markdown = await response.text();
    const html = await marked(markdown);

    this.contentCache.set(slug, html);

    return {
      ...post,
      content: html,
    };
  }
}

Blog Routes

Index Route

File: app/routes/blog/index.ts

import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type BlogService from 'crunchy-blog/services/blog';

export default class BlogIndexRoute extends Route {
  @service declare blog: BlogService;

  async model() {
    return await this.blog.loadPosts();
  }
}

Post Route

File: app/routes/blog/post.ts

import Route from '@ember/routing/route';
import { service } from '@ember/service';
import type BlogService from 'crunchy-blog/services/blog';
import type RouterService from '@ember/routing/router-service';

export default class BlogPostRoute extends Route {
  @service declare blog: BlogService;
  @service declare router: RouterService;

  async model(params: { slug: string }) {
    const post = await this.blog.loadPost(params.slug);

    if (!post) {
      // Redirect to blog index if post not found
      this.router.transitionTo('blog.index');
      return null;
    }

    return post;
  }
}

Blog Templates

Index Template

File: app/templates/blog/index.gts

import { LinkTo } from '@ember/routing';
import type { TemplateOnlyComponent as TOC } from '@ember/component/template-only';

interface BlogPost {
  id: string;
  title: string;
  date: string;
  slug: string;
  excerpt: string;
}

interface BlogIndexSignature {
  Args: {
    model: BlogPost[];
  };
}

const BlogIndexTemplate: TOC<BlogIndexSignature> = <template>
  <div class="bg-yellow-500 py-16">
    <div class="max-w-4xl mx-auto px-8">
      <h1 class="font-extrabold text-4xl mb-6 text-center text-gray-900">
        Crunchy Blog
      </h1>
      <p class="text-lg text-gray-700 text-center max-w-2xl mx-auto">
        Insights, tutorials, and updates from the Crunchy Bananas team
      </p>
    </div>
  </div>

  <div class="bg-gray-100 py-16">
    <div class="max-w-4xl mx-auto px-8">
      <div class="space-y-6">
        {{#each @model as |post|}}
          <article
            class="group bg-white rounded-lg shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden"
          >
            <div class="p-6 md:p-8">
              <h2 class="text-2xl md:text-3xl font-bold mb-2 leading-tight">
                <LinkTo
                  @route="blog.post"
                  @model={{post.slug}}
                  class="text-gray-900 group-hover:text-blue-600 transition-colors"
                >
                  {{post.title}}
                </LinkTo>
              </h2>
              <time class="text-sm font-medium text-gray-500 block mb-4">
                {{post.date}}
              </time>
              <p class="text-base text-gray-700 leading-relaxed mb-5">
                {{post.excerpt}}
              </p>
              <LinkTo
                @route="blog.post"
                @model={{post.slug}}
                class="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 font-semibold transition-all group-hover:gap-3"
              >
                <span>Read article</span>
                <span class="text-xl">β†’</span>
              </LinkTo>
            </div>
          </article>
        {{/each}}
      </div>
    </div>
  </div>
</template>;

export default BlogIndexTemplate;

Type Safety: The TOC<BlogIndexSignature> provides full type-checking for the @model argument in your template.

Post Template

File: app/templates/blog/post.gts

import { LinkTo } from '@ember/routing';
import { htmlSafe } from '@ember/template';
import type { TemplateOnlyComponent as TOC } from '@ember/component/template-only';

interface BlogPostWithContent {
  id: string;
  title: string;
  date: string;
  slug: string;
  excerpt: string;
  content: string;
}

interface BlogPostSignature {
  Args: {
    model: BlogPostWithContent | null;
  };
}

const BlogPostTemplate: TOC<BlogPostSignature> = <template>
  {{#if @model}}
    <div class="bg-white py-16">
      <div class="max-w-4xl mx-auto px-8">
        <nav class="mb-10">
          <LinkTo
            @route="blog.index"
            class="inline-flex items-center gap-2 text-blue-600 hover:text-blue-800 font-semibold transition-colors"
          >
            <span class="text-xl">←</span>
            <span>Back to all posts</span>
          </LinkTo>
        </nav>

        <article
          class="prose prose-lg max-w-none prose-headings:text-gray-900 prose-a:text-blue-600 prose-code:bg-gray-100 prose-code:text-gray-900 prose-pre:bg-gray-900"
        >
          <header class="not-prose mb-4">
            <time class="text-gray-500 text-sm font-medium">
              {{@model.date}}
            </time>
          </header>

          {{! IMPORTANT: Only use htmlSafe with trusted content! }}
          {{! Never use with user-generated content without sanitization. }}
          {{htmlSafe @model.content}}
        </article>
      </div>
    </div>
  {{/if}}
</template>;

export default BlogPostTemplate;

Type Safety: The nullable model type (BlogPostWithContent | null) matches the route's behavior when a post isn't found.

**Security Note: The htmlSafe helper is used here because we're rendering markdown that we control. **Never** use htmlSafe with user-generated content without proper sanitization!

Data Structure

Blog Index JSON

File: public/blog-posts/index.json

{
  "posts": [
    {
      "id": "building-this-site-in-10-minutes",
      "title": "How We Built This Blog in About 10 Minutes with Copilot πŸš€",
      "date": "2025-12-11",
      "slug": "building-this-site-in-10-minutes",
      "excerpt": "Can you go from zero to a working blog in about 10 minutes with modern tools and AI? Here's how we did it, what Copilot actually helped with, and where we still had to think."
    },
    {
      "id": "tailwind-setup-ember-vite",
      "title": "Tailwind CSS v4 with Ember & Vite: Complete Setup Guide",
      "date": "2025-12-16",
      "slug": "tailwind-setup-ember-vite",
      "excerpt": "Everything you need to know about setting up Tailwind CSS v4 with Ember and Viteβ€”from installation to styling to the gotchas that cost us time."
    }
  ]
}

Markdown Files

File: public/blog-posts/your-post-slug.md

Each blog post is a simple markdown file:

# Your Post Title

Your content here in **markdown** format.

## Subheading

- List items
- More content

Code blocks work too:

\`\`\`javascript
console.log('Hello, world!');
\`\`\`

Project Structure

Here's the complete file structure for the blog:

app/
β”œβ”€β”€ routes/
β”‚   β”œβ”€β”€ blog/
β”‚   β”‚   β”œβ”€β”€ index.ts          # Blog index route
β”‚   β”‚   └── post.ts           # Individual post route
β”‚   └── application.ts        # Root route (optional)
β”œβ”€β”€ services/
β”‚   └── blog.ts               # Blog service for data loading
β”œβ”€β”€ templates/
β”‚   β”œβ”€β”€ blog/
β”‚   β”‚   β”œβ”€β”€ index.gts         # Blog index template
β”‚   β”‚   └── post.gts          # Post template
β”‚   └── application.gts       # Root template with nav/footer
└── styles/
    └── app.css               # Tailwind imports

public/
└── blog-posts/
    β”œβ”€β”€ index.json            # Post metadata
    β”œβ”€β”€ post-one.md           # Post markdown files
    └── post-two.md

Router Configuration

File: app/router.ts

import EmberRouter from '@ember/routing/router';
import config from 'crunchy-blog/config/environment';

export default class Router extends EmberRouter {
  location = config.locationType;
  rootURL = config.rootURL;
}

Router.map(function () {
  this.route('blog', function () {
    this.route('post', { path: '/:slug' });
  });
});

This creates the following routes:

  • /blog - Blog index (list of posts)
  • /blog/your-post-slug - Individual post

Installing Dependencies

You'll need these packages:

pnpm add marked
pnpm add -D @types/marked

For the full Tailwind v4 setup, see the Tailwind CSS v4 with Ember & Vite: Complete Setup Guide post.

Testing Your Setup

  1. Create a test post:

Add a file at public/blog-posts/hello-world.md:

# Hello World

This is my first blog post!
  1. Update index.json:
{
  "posts": [
    {
      "id": "hello-world",
      "title": "Hello World",
      "date": "2025-12-11",
      "slug": "hello-world",
      "excerpt": "My first post!"
    }
  ]
}
  1. Start your dev server:
pnpm start
  1. Visit the blog:

Navigate to http://localhost:4200/blog to see your post!

Common Issues

"Cannot find module 'marked'"

Make sure you installed the marked package:

pnpm add marked

TypeScript errors about @model

This is a Glint/template type checking issue. The code will work at runtime. To fix, you can either:

  • Add proper template signatures (advanced)
  • Disable Glint strict mode temporarily
  • Ignore the warnings (they don't affect functionality)

Markdown not rendering

Check that:

  1. Your markdown file is in public/blog-posts/
  2. The slug in index.json matches the filename (without .md)
  3. The marked package is imported in your service

Next Steps

Once you have the basics working, you can:

  • Add syntax highlighting with highlight.js
  • Implement search with lunr.js
  • Add an RSS feed
  • Generate Open Graph meta tags
  • Add categories and tags
  • Implement pagination

Questions?

If you run into issues with these examples, please reach out or check the full source code for this blog (if public).


Last Updated: December 11, 2025
Verified Against: Ember 6.9.0, TypeScript 5.9.3, Vite 7.x