Skip to main content

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 transformation
  • default:'fallback' - Provide fallback values
  • join:'delimiter' - Join arrays with custom delimiter
  • dateFormat:'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.