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@modelargument 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
modeltype (BlogPostWithContent | null) matches the route's behavior when a post isn't found.
**Security Note: The
htmlSafehelper is used here because we're rendering markdown that we control. **Never** usehtmlSafewith 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
- Create a test post:
Add a file at public/blog-posts/hello-world.md:
# Hello World
This is my first blog post!
- Update index.json:
{
"posts": [
{
"id": "hello-world",
"title": "Hello World",
"date": "2025-12-11",
"slug": "hello-world",
"excerpt": "My first post!"
}
]
}
- Start your dev server:
pnpm start
- 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:
- Your markdown file is in
public/blog-posts/ - The slug in
index.jsonmatches the filename (without.md) - The
markedpackage 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