View System and Template Rendering
The Hypermodern platform includes a powerful template rendering system that allows you to build dynamic HTML applications alongside your API endpoints. This chapter explores how to use the view system to create server-rendered web applications with the same ease and type safety you expect from Hypermodern.
Understanding the View System
The view system bridges the gap between API-first development and traditional web applications. While Hypermodern excels at building APIs and real-time applications, sometimes you need to render HTML directly on the server - whether for SEO, progressive enhancement, or hybrid applications.
Key Features
- Mustache-style syntax familiar from popular templating engines
- Template inheritance for consistent layouts
- Conditional rendering and loops
- Built-in filters for data transformation
- Seamless integration with existing HTTP routes
- Type-safe data binding through Dart's type system
Setting Up Templates
Templates are stored in your project's lib/resources/view/ directory with .html extensions. This keeps them organized and easily accessible to the template engine.
Basic Template Structure
<!-- lib/resources/view/welcome.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
</head>
<body>
<header>
<h1>{{title}}</h1>
</header>
<main>
<p>{{message}}</p>
{{#if user}}
<p>Welcome back, {{user.name}}!</p>
{{/if}}
</main>
</body>
</html>
Rendering from Routes
The view system integrates seamlessly with Hypermodern's routing system. Instead of returning JSON data, you return a view response:
import 'package:hypermodern_server/hypermodern_server.dart';
class WelcomeController {
static Future<dynamic> index(RequestData data) async {
return view('welcome', {
'title': 'Welcome to Hypermodern',
'message': 'Building the future of web applications',
'user': data.user, // Assuming user is available in request context
});
}
}
// Register the route
router.addHttpRoute('GET', '/', WelcomeController.index);
Template Syntax Reference
Variable Interpolation
Variables are rendered using double curly braces:
<h1>{{title}}</h1>
<p>User: {{user.name}}</p>
<p>Email: {{user.email}}</p>
<p>Posts: {{user.posts.length}}</p>
Conditional Rendering
The view system supports if/else conditionals:
{{#if user.isAdmin}}
<div class="admin-panel">
<h2>Admin Controls</h2>
<button>Manage Users</button>
</div>
{{#elseif user.isActive}}
<div class="user-dashboard">
<h2>Welcome, {{user.name}}</h2>
</div>
{{else}}
<div class="activation-notice">
<p>Please activate your account to continue.</p>
</div>
{{/if}}
Loops and Iteration
Render lists of data with the each helper:
<div class="user-list">
{{#each users}}
<div class="user-card">
<h3>{{name}}</h3>
<p>{{email}}</p>
<span class="role">{{role}}</span>
</div>
{{/each}}
</div>
{{#if users.isEmpty}}
<p>No users found.</p>
{{/if}}
Filters and Data Transformation
Filters allow you to transform data during rendering:
<h1>{{title | uppercase}}</h1>
<p>{{description | default:'No description available'}}</p>
<p>Tags: {{tags | join:', '}}</p>
<p>Created: {{createdAt | dateFormat:'yyyy-MM-dd'}}</p>
Available filters:
uppercase/lowercase- Text case transformationdefault:'fallback'- Provide fallback valuesjoin:'delimiter'- Join arrays with custom delimiterdateFormat:'pattern'- Format DateTime objects
Template Inheritance
One of the most powerful features is template inheritance, allowing you to create consistent layouts across your application.
Creating a Layout
<!-- lib/resources/view/layouts/app.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title | default:'Hypermodern App'}}</title>
<link rel="stylesheet" href="/css/app.css">
{{{head}}}
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<a href="/">Hypermodern</a>
</div>
<div class="nav-links">
{{#if user}}
<a href="/dashboard">Dashboard</a>
<a href="/profile">Profile</a>
<a href="/logout">Logout</a>
{{else}}
<a href="/login">Login</a>
<a href="/register">Register</a>
{{/if}}
</div>
</nav>
<main class="main-content">
{{{content}}}
</main>
<footer class="footer">
{{{footer}}}
</footer>
<script src="/js/app.js"></script>
{{{scripts}}}
</body>
</html>
Extending Layouts
<!-- lib/resources/view/dashboard.html -->
{{#extends layouts/app}}
{{#define head}}
<link rel="stylesheet" href="/css/dashboard.css">
{{/define}}
{{#define content}}
<div class="dashboard">
<h1>Dashboard</h1>
<div class="stats-grid">
{{#each stats}}
<div class="stat-card">
<h3>{{title}}</h3>
<p class="stat-value">{{value}}</p>
</div>
{{/each}}
</div>
<div class="recent-activity">
<h2>Recent Activity</h2>
{{#each activities}}
<div class="activity-item">
<span class="activity-time">{{timestamp | dateFormat:'HH:mm'}}</span>
<span class="activity-text">{{description}}</span>
</div>
{{/each}}
</div>
</div>
{{/define}}
{{#define scripts}}
<script src="/js/dashboard.js"></script>
{{/define}}
Partial Templates
Break your templates into reusable components with partials:
<!-- lib/resources/view/partials/user-card.html -->
<div class="user-card">
<img src="{{avatar | default:'/images/default-avatar.png'}}" alt="{{name}}">
<div class="user-info">
<h3>{{name}}</h3>
<p>{{email}}</p>
<span class="role {{role | lowercase}}">{{role}}</span>
</div>
{{#if isOnline}}
<span class="status online">Online</span>
{{else}}
<span class="status offline">Offline</span>
{{/if}}
</div>
Include partials in your templates:
<div class="team-members">
{{#each team.members}}
{{> partials/user-card}}
{{/each}}
</div>
Advanced Features
Switch Statements
For complex conditional logic, use switch statements:
{{#switch user.role}}
{{#case 'admin'}}
<div class="admin-interface">
<h2>Administrator Panel</h2>
<!-- Admin-specific content -->
</div>
{{/case}}
{{#case 'moderator'}}
<div class="moderator-tools">
<h2>Moderator Tools</h2>
<!-- Moderator-specific content -->
</div>
{{/case}}
{{#default}}
<div class="user-dashboard">
<h2>User Dashboard</h2>
<!-- Regular user content -->
</div>
{{/default}}
{{/switch}}
Complex Expressions
The template engine supports more complex expressions:
{{#if user.age >= 18}}
<p>Welcome, adult user!</p>
{{/if}}
<p>Total Price: ${{price * quantity | number:'0.00'}}</p>
<p>Status: {{isActive ? 'Active' : 'Inactive'}}</p>
<p>Progress: {{(completed / total * 100) | number:'0'}}%</p>
Integrating with Hypermodern Features
Type-Safe View Data
Create strongly-typed view models for better maintainability:
class DashboardViewModel {
final String title;
final User user;
final List<StatCard> stats;
final List<Activity> activities;
DashboardViewModel({
required this.title,
required this.user,
required this.stats,
required this.activities,
});
Map<String, dynamic> toJson() => {
'title': title,
'user': user.toJson(),
'stats': stats.map((s) => s.toJson()).toList(),
'activities': activities.map((a) => a.toJson()).toList(),
};
}
// In your controller
static Future<dynamic> dashboard(RequestData data) async {
final viewModel = DashboardViewModel(
title: 'User Dashboard',
user: data.user!,
stats: await StatsService.getUserStats(data.user!.id),
activities: await ActivityService.getRecentActivities(data.user!.id),
);
return view('dashboard', viewModel.toJson());
}
Mixing Views and API Endpoints
You can serve both HTML views and JSON APIs from the same application:
class ProductController {
// Serve HTML page for browsers
static Future<dynamic> index(RequestData data) async {
final products = await ProductService.getAll();
return view('products/index', {
'products': products.map((p) => p.toJson()).toList(),
});
}
// Serve JSON for API clients
static Future<dynamic> apiIndex(RequestData data) async {
final products = await ProductService.getAll();
return {
'products': products.map((p) => p.toJson()).toList(),
};
}
}
// Register both routes
router.addHttpRoute('GET', '/products', ProductController.index);
router.addHttpRoute('GET', '/api/products', ProductController.apiIndex);
Form Handling and CSRF Protection
Handle form submissions with proper validation:
class ContactController {
static Future<dynamic> showForm(RequestData data) async {
return view('contact/form', {
'csrfToken': generateCSRFToken(data.session),
});
}
static Future<dynamic> submitForm(RequestData data) async {
// Validate CSRF token
if (!validateCSRFToken(data.body['csrf_token'], data.session)) {
return view('contact/form', {
'error': 'Invalid form submission',
'csrfToken': generateCSRFToken(data.session),
});
}
// Process form data
try {
await ContactService.sendMessage(
name: data.body['name'],
email: data.body['email'],
message: data.body['message'],
);
return view('contact/success', {
'message': 'Thank you for your message!',
});
} catch (e) {
return view('contact/form', {
'error': 'Failed to send message. Please try again.',
'formData': data.body,
'csrfToken': generateCSRFToken(data.session),
});
}
}
}
Performance Considerations
Template Caching
In production, implement template caching to improve performance:
class CachedViewRenderer {
static final Map<String, String> _templateCache = {};
static Future<String> renderCached(String template, Map<String, dynamic> data) async {
// Check if template is cached
if (!_templateCache.containsKey(template)) {
_templateCache[template] = await loadTemplate(template);
}
return renderTemplate(_templateCache[template]!, data);
}
}
Optimizing Data Loading
Load only the data you need for each view:
class OptimizedController {
static Future<dynamic> userProfile(RequestData data) async {
final userId = data.pathParams['id'];
// Load only necessary data
final user = await UserService.getBasicInfo(userId);
final recentPosts = await PostService.getRecent(userId, limit: 5);
return view('users/profile', {
'user': user.toJson(),
'recentPosts': recentPosts.map((p) => p.toJson()).toList(),
});
}
}
Best Practices
1. Organize Templates Logically
lib/resources/view/
├── layouts/
│ ├── app.html
│ ├── admin.html
│ └── email.html
├── pages/
│ ├── home.html
│ ├── about.html
│ └── contact/
│ ├── form.html
│ └── success.html
├── partials/
│ ├── header.html
│ ├── footer.html
│ ├── navigation.html
│ └── forms/
│ ├── input.html
│ └── button.html
└── emails/
├── welcome.html
└── notification.html
2. Use Semantic HTML
Write accessible, semantic HTML in your templates:
<article class="blog-post">
<header>
<h1>{{post.title}}</h1>
<time datetime="{{post.publishedAt | isoDate}}">
{{post.publishedAt | dateFormat:'MMMM d, yyyy'}}
</time>
<address>By {{post.author.name}}</address>
</header>
<div class="post-content">
{{{post.content}}}
</div>
<footer>
<nav aria-label="Post tags">
{{#each post.tags}}
<a href="/tags/{{slug}}" class="tag">{{name}}</a>
{{/each}}
</nav>
</footer>
</article>
3. Implement Progressive Enhancement
Start with server-rendered HTML and enhance with JavaScript:
<!-- Server-rendered form with progressive enhancement -->
<form action="/contact" method="POST" class="contact-form" data-enhance="true">
<input type="hidden" name="csrf_token" value="{{csrfToken}}">
<div class="form-group">
<label for="name">Name</label>
<input type="text" id="name" name="name" value="{{formData.name}}" required>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" name="email" value="{{formData.email}}" required>
</div>
<div class="form-group">
<label for="message">Message</label>
<textarea id="message" name="message" required>{{formData.message}}</textarea>
</div>
<button type="submit">Send Message</button>
</form>
<script>
// Progressive enhancement
document.addEventListener('DOMContentLoaded', function() {
const form = document.querySelector('[data-enhance="true"]');
if (form) {
enhanceForm(form); // Add AJAX submission, validation, etc.
}
});
</script>
Real-World Example: Blog Application
Let's build a complete blog application using the view system:
Models and Services
class BlogPost {
final String id;
final String title;
final String content;
final String slug;
final DateTime publishedAt;
final User author;
final List<String> tags;
BlogPost({
required this.id,
required this.title,
required this.content,
required this.slug,
required this.publishedAt,
required this.author,
required this.tags,
});
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'content': content,
'slug': slug,
'publishedAt': publishedAt.toIso8601String(),
'author': author.toJson(),
'tags': tags,
};
}
Controllers
class BlogController {
static Future<dynamic> index(RequestData data) async {
final posts = await BlogService.getPublishedPosts();
return view('blog/index', {
'title': 'Blog',
'posts': posts.map((p) => p.toJson()).toList(),
});
}
static Future<dynamic> show(RequestData data) async {
final slug = data.pathParams['slug'];
final post = await BlogService.getBySlug(slug);
if (post == null) {
return view('errors/404', {'title': 'Post Not Found'});
}
return view('blog/show', {
'title': post.title,
'post': post.toJson(),
});
}
}
Templates
<!-- lib/resources/view/blog/index.html -->
{{#extends layouts/app}}
{{#define content}}
<div class="blog-index">
<header class="page-header">
<h1>Latest Posts</h1>
</header>
<div class="posts-grid">
{{#each posts}}
<article class="post-card">
<h2><a href="/blog/{{slug}}">{{title}}</a></h2>
<p class="post-meta">
By {{author.name}} on
<time datetime="{{publishedAt}}">
{{publishedAt | dateFormat:'MMMM d, yyyy'}}
</time>
</p>
<div class="post-excerpt">
{{content | truncate:200}}
</div>
<div class="post-tags">
{{#each tags}}
<span class="tag">{{this}}</span>
{{/each}}
</div>
</article>
{{/each}}
</div>
{{#if posts.isEmpty}}
<p class="no-posts">No posts available yet.</p>
{{/if}}
</div>
{{/define}}
The view system provides a powerful way to build server-rendered applications while maintaining the type safety and developer experience that makes Hypermodern special. Whether you're building a traditional web application, adding server-side rendering to an API, or creating hybrid applications, the view system gives you the tools you need to create engaging user experiences.
No Comments