Example: SSR Site

A complete, production-ready server-side rendered site built with SpaceNode. This example demonstrates authentication, admin panel, cookie sessions, CSRF protection, flash messages, DTO validation, role-based access control, and the built-in template engine — all without any client-side JavaScript frameworks.

Zero client-side JS — Every page is rendered entirely on the server. The browser receives pure HTML + CSS. No React, no Vue, no bundlers.

What You'll Build

Technology Stack

ComponentTechnology
FrameworkSpaceNode (zero dependencies)
Template EngineBuilt-in AOT compiler with XSS auto-escaping
DatabaseMongoDB + Mongoose
Password Hashingbcrypt (salt rounds: 10)
Session ManagementCookie-based tokens stored in MongoDB
Configurationdotenv (.env file)

Project Structure

examples/site/
├── index.js                  // entry point: guards + createApp + app.render()
├── .env                      // MONGO_URI, PORT, SECRET
├── package.json
│
├── models/
│   ├── user.model.js         // User schema: name, email, password (bcrypt), role
│   └── token.model.js        // Token schema: session tokens with userId ref
│
├── modules/
│   ├── auth/                 // login, register, logout
│   │   ├── module.js         // routes: GET/POST /auth/login, /auth/register, /auth/logout
│   │   ├── auth.controller.js// page rendering + form submission handlers
│   │   ├── auth.service.js   // register(), login(), logout() business logic
│   │   └── auth.dto.js       // loginDto, registerDto validation schemas
│   │
│   ├── profile/              // user profile (requires authentication)
│   │   ├── module.js         // pipe: ['cookieAuth'], GET /profile
│   │   └── profile.controller.js
│   │
│   └── admin/                // admin panel (requires auth + admin role)
│       ├── module.js         // pipe: ['cookieAuth', 'admin'], GET /admin
│       └── admin.controller.js
│
├── views/
│   ├── settings.js           // layout name + global variables (siteName, year)
│   ├── layout.html           // HTML wrapper: head, nav, flash messages, footer
│   ├── pages/
│   │   ├── home.html         // home page content
│   │   ├── login.html        // login form with CSRF field
│   │   ├── register.html     // registration form with CSRF field
│   │   ├── profile.html      // user profile display
│   │   └── admin.html        // admin dashboard with user table
│   └── partials/
│       ├── nav.html          // navigation: conditional links based on auth state
│       └── footer.html       // footer component
│
└── public/                   // static files (served via static: './public')
    ├── css/
    │   ├── main.css          // shared styles (nav, layout, alerts)
    │   ├── home.css          // home page styles
    │   ├── auth.css          // login/register form styles
    │   └── admin.css         // admin panel styles
    ├── images/               // images, icons, favicons → /images/logo.png
    └── js/                   // client-side scripts     → /js/app.js

Entry Point — index.js

The entire application setup fits in a single file. It connects to MongoDB, defines guards, creates the app with views and static files enabled, and registers the home page:

// ─────────────────────────────────────────────
// SpaceNode — SSR Site Example
// ─────────────────────────────────────────────

import 'dotenv/config'
import mongoose from 'mongoose'
import { createApp, defineGuard, HttpError } from 'spacenode'
import { Token } from './models/token.model.js'
import { User } from './models/user.model.js'

// ── MongoDB ──
await mongoose.connect(process.env.MONGO_URI)

// ── Cookie-based auth guard ──
defineGuard('cookieAuth', () => async (request) => {
  const token = request.cookies.token
  if (!token) throw new HttpError(401, 'Authentication required')

  const session = await Token.findOne({ token })
  if (!session) throw new HttpError(401, 'Session expired')

  const user = await User.findById(session.userId)
  if (!user) throw new HttpError(401, 'User not found')

  return { user: user.toSafe() }
})

// ── Admin guard ──
defineGuard('admin', () => (request) => {
  if (request.user?.role !== 'admin') {
    throw new HttpError(403, 'Admin access only')
  }
})

// ── App ──
const app = await createApp({
  baseUrl: import.meta.url,
  views: './views',       // enable template engine
  static: './public',     // serve CSS files
  watch: true,            // auto-restart in dev
})

// ── Home page ──
app.render('GET', '/', 'pages/home', { title: 'Home' })

// ── Start ──
app.listen(process.env.PORT || 3000)
Key points: The views option enables the built-in template engine and auto-loads settings.js from the views directory. The static option serves CSS files from public/. Guards are defined globally with defineGuard() and referenced by name in module pipes.

Guards — Authentication & Authorization

This site uses two guards that form a pipeline chain:

cookieAuth Guard

Reads the token cookie, looks up the session in MongoDB, and attaches the user object to the request. If any step fails, a 401 error is thrown.

When the guard returns { user: ... }, SpaceNode merges this into the request object. After the guard runs, request.user is available in all downstream controllers — and in templates (since controllers pass it to render()).

admin Guard

Runs after cookieAuth in the pipeline. It only checks request.user.role — if it's not 'admin', a 403 error is thrown. This guard is lightweight because the user is already loaded by the previous guard.

How Guards Are Applied

// modules/profile/module.js
export default {
  name: 'profile',
  prefix: '/profile',
  pipe: ['cookieAuth'],          // only authenticated users
  routes: [
    ['GET', '/', 'profilePage'],
  ],
}

// modules/admin/module.js
export default {
  name: 'admin',
  prefix: '/admin',
  pipe: ['cookieAuth', 'admin'], // authenticated + admin role
  routes: [
    ['GET', '/', 'dashboard'],
  ],
}

The pipe array in module.js applies guards to all routes in the module. Guards run in order — cookieAuth first, then admin.

Database Models

User Model

Mongoose schema with automatic password hashing, password verification, and a toSafe() method that strips sensitive fields:

import mongoose from 'mongoose'
import bcrypt from 'bcrypt'

const userSchema = new mongoose.Schema({
  name:     { type: String, required: true, minlength: 2, maxlength: 50 },
  email:    { type: String, required: true, unique: true, lowercase: true, trim: true },
  password: { type: String, required: true },
  role:     { type: String, enum: ['user', 'admin'], default: 'user' },
}, { timestamps: true })

// Auto-hash password before saving
userSchema.pre('save', async function () {
  if (this.isModified('password')) {
    this.password = await bcrypt.hash(this.password, 10)
  }
})

// Compare plain-text password with hash
userSchema.methods.verifyPassword = function (plain) {
  return bcrypt.compare(plain, this.password)
}

// Strip password from output
userSchema.methods.toSafe = function () {
  const obj = this.toObject()
  delete obj.password
  obj.id = obj._id
  return obj
}

export const User = mongoose.model('User', userSchema)

Token Model

Session tokens stored in MongoDB. Each token links to a user via userId. The generate() static method creates a cryptographically secure random token:

import mongoose from 'mongoose'
import { randomBytes } from 'node:crypto'

const tokenSchema = new mongoose.Schema({
  token:  { type: String, required: true, unique: true, index: true },
  userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
}, { timestamps: true })

tokenSchema.statics.generate = function (userId) {
  const token = randomBytes(32).toString('hex')
  return this.create({ token, userId })
}

export const Token = mongoose.model('Token', tokenSchema)

Auth Module — Login, Register, Logout

The auth module handles all authentication flows: rendering login/register forms, processing form submissions with DTO validation, and managing sessions.

Module Definition

// modules/auth/module.js
export default {
  name: 'auth',
  prefix: '/auth',
  pipe: ['csrf'],           // CSRF protection on all auth routes
  routes: [
    ['GET',  '/login',    'loginPage'],
    ['POST', '/login',    'login',    ['dto:loginDto']],
    ['GET',  '/register', 'registerPage'],
    ['POST', '/register', 'register', ['dto:registerDto']],
    ['GET',  '/logout',   'logout'],
  ],
}
CSRF protection — The csrf pipe is applied to the entire auth module. SpaceNode automatically generates a CSRF token and injects csrfToken and csrfField into every rendered template. Form templates include [= raw(csrfField)] to embed the hidden input.

DTO Validation

Form data is validated before reaching the controller using DTO schemas:

// modules/auth/auth.dto.js
export const loginDto = {
  email:    ['string', 'required', 'email'],
  password: ['string', 'required', 'min:6'],
}

export const registerDto = {
  name:     ['string', 'required', 'min:2', 'max:50'],
  email:    ['string', 'required', 'email'],
  password: ['string', 'required', 'min:6'],
}

The dto:loginDto pipe in the route definition automatically validates request.body against this schema. If validation fails, SpaceNode returns a 400 error with details before the controller runs.

Auth Service

Business logic is separated from controllers into a service object. SpaceNode auto-discovers it from the module directory and injects it into controller functions:

// modules/auth/auth.service.js
import { User } from '../../models/user.model.js'
import { Token } from '../../models/token.model.js'

export const authService = {
  async register(name, email, password) {
    const exists = await User.findOne({ email })
    if (exists) return null
    const user = await User.create({ name, email, password })
    return user.toSafe()
  },

  async login(email, password) {
    const user = await User.findOne({ email })
    if (!user) return null
    const valid = await user.verifyPassword(password)
    if (!valid) return null
    const session = await Token.generate(user._id)
    return { user: user.toSafe(), token: session.token }
  },

  async logout(token) {
    await Token.deleteOne({ token })
  },
}

Auth Controller

Controller functions receive the request context as the first argument and all services as the second argument. They render pages and handle form actions:

// modules/auth/auth.controller.js

// ── Pages ──
export async function loginPage(request) {
  await request.render('pages/login', { title: 'Login' })
}

export async function registerPage(request) {
  await request.render('pages/register', { title: 'Register' })
}

// ── Actions ──
export async function login(request, { authService }) {
  const result = await authService.login(request.body.email, request.body.password)
  if (!result) {
    request.flash('error', 'Invalid email or password')
    return request.redirect('/auth/login')
  }

  request.cookie('token', result.token, {
    httpOnly: true,
    secure: false,
    sameSite: 'Lax',
    maxAge: 7 * 24 * 60 * 60,  // 7 days
  })

  request.flash('success', 'Successfully logged in!')
  request.redirect('/profile')
}

export async function register(request, { authService }) {
  const user = await authService.register(
    request.body.name, request.body.email, request.body.password
  )
  if (!user) {
    request.flash('error', 'A user with this email already exists')
    return request.redirect('/auth/register')
  }

  request.flash('success', 'Account created! Please sign in.')
  request.redirect('/auth/login')
}

export async function logout({ cookies, cookie, redirect }, { authService }) {
  const token = cookies.token
  if (token) {
    await authService.logout(token)
    cookie('token', '', { maxAge: 0 })
  }
  redirect('/')
}
Flash messagesrequest.flash('success', 'message') stores a message in a cookie that is displayed once on the next page load and then cleared automatically. The layout template handles rendering all flash types (success, error, info, warning).

Template System

All templates are in the views/ directory. The engine auto-discovers settings.js to configure the layout and global variables.

settings.js — Configuration

export default {
  layout: 'layout',         // wraps every page with layout.html
  globals: {
    siteName: 'SpaceNode Site',
    year: new Date().getFullYear(),
  },
}

The globals object is merged into every template's scope. So [= siteName] and [= year] are available in all templates without passing them explicitly.

layout.html — Page Wrapper

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>[= title][= siteName]</title>
  <link rel="stylesheet" href="/css/main.css">
  [= raw(head)]             <!-- slot for per-page CSS -->
</head>
<body>
  [> partials/nav]          <!-- include from partials/ -->
  <main>
    <!-- Flash messages -->
    [# if flashes.success]
      [# each flashes.success as msg]
        <div class="alert alert-success">[= msg]</div>
      [/each]
    [/if]
    [# if flashes.error]
      [# each flashes.error as msg]
        <div class="alert alert-error">[= msg]</div>
      [/each]
    [/if]
    [= raw(body)]             <!-- page content goes here -->
  </main>
  [> partials/footer]       <!-- include from partials/ -->
</body>
</html>

partials/nav.html — Conditional Navigation

The navigation shows different links based on authentication state and user role:

<nav>
  <a href="/" class="logo">⚡ [= siteName]</a>
  <div class="links">
    <a href="/">Home</a>
    [# if user]
      <a href="/profile">Profile</a>
      [# if user.role === 'admin']
        <a href="/admin">Admin</a>
      [/if]
      <a href="/auth/logout">Logout</a>
    [# else]
      <a href="/auth/login">Login</a>
      <a href="/auth/register">Register</a>
    [/if]
  </div>
</nav>

pages/login.html — Form with CSRF & Block

[# block head]<link rel="stylesheet" href="/css/auth.css">[/block]

<div class="card auth-card">
  <h1>Login</h1>
  <form method="POST" action="/auth/login">
    [= raw(csrfField)]       <!-- hidden CSRF input -->
    <div class="form-group">
      <label for="email">Email</label>
      <input type="email" id="email" name="email" required>
    </div>
    <div class="form-group">
      <label for="password">Password</label>
      <input type="password" id="password" name="password" required minlength="6">
    </div>
    <button type="submit" class="btn btn-full">Sign In</button>
  </form>
</div>
Block system[# block head]...content...[/block] captures HTML and passes it up to the layout. The layout inserts it via [= raw(head)]. This lets each page inject its own CSS/JS into the <head> without affecting other pages.

admin.html — Loops & Helpers

The admin panel demonstrates loops ([# each]), conditionals ([# if]), and the date() helper:

[# block head]<link rel="stylesheet" href="/css/admin.css">[/block]

<h1>Admin Panel</h1>

<!-- Statistics cards -->
<div class="stats-grid">
  <div class="stat-card">
    <div class="number">[= stats.totalUsers]</div>
    <div class="label">Users</div>
  </div>
  <div class="stat-card">
    <div class="number">[= stats.admins]</div>
    <div class="label">Admins</div>
  </div>
</div>

<!-- User table with loop -->
<table>
  <thead>
    <tr><th>Name</th><th>Email</th><th>Role</th><th>Registered</th></tr>
  </thead>
  <tbody>
    [# each users as u]
      <tr>
        <td>[= u.name]</td>
        <td>[= u.email]</td>
        <td>
          [# if u.role === 'admin']
            <span class="badge badge-admin">[= u.role]</span>
          [# else]
            <span class="badge badge-user">[= u.role]</span>
          [/if]
        </td>
        <td>[= date(u.createdAt, 'DD.MM.YYYY')]</td>
      </tr>
    [/each]
  </tbody>
</table>

<!-- Recently registered (conditional section) -->
[# if stats.recent.length]
  <div class="card">
    <h2>Recently Registered</h2>
    [# each stats.recent as u]
      <div><strong>[= u.name]</strong> <span>[= u.email]</span></div>
    [/each]
  </div>
[/if]

profile.html — Authenticated User Data

<div class="card">
  <h1>Profile</h1>
  <table>
    <tr><th>Name</th><td>[= user.name]</td></tr>
    <tr><th>Email</th><td>[= user.email]</td></tr>
    <tr>
      <th>Role</th>
      <td>
        [# if user.role === 'admin']
          <span class="badge badge-admin">[= user.role]</span>
        [# else]
          <span class="badge badge-user">[= user.role]</span>
        [/if]
      </td>
    </tr>
    <tr><th>ID</th><td>[= user.id]</td></tr>
  </table>
  <a href="/auth/logout" class="btn btn-outline">Sign Out</a>
</div>

Request Flow

Here's what happens when a user visits /profile:

  1. Router matches GET /profile → profile module
  2. Pipeline runs the cookieAuth guard:
    • Reads token from cookies
    • Looks up session in MongoDB (Token.findOne)
    • Finds user by ID (User.findById)
    • Returns { user: ... } → merged into request.user
  3. Controller profilePage() runs:
    • Calls request.render('pages/profile', { title: 'Profile', user: request.user })
  4. Template engine:
    • Compiles pages/profile.html to a JS function (cached)
    • Merges { title, user } with globals (siteName, year)
    • Executes the compiled function → produces HTML
    • Extracts [# block head] content
    • Renders layout.html with body = profile HTML + blocks
    • Includes partials/nav.html and partials/footer.html
  5. Response: complete HTML page sent to browser

Getting Started

1. Prerequisites

Node.js 18+ and MongoDB installed and running.

2. Create .env

# examples/site/.env
MONGO_URI=mongodb://127.0.0.1:27017/spacenode-site
PORT=3000
SECRET=change-me-to-random-string

3. Install & Run

cd examples/site
npm install
npm start

Open http://localhost:3000 in your browser.

4. Become Admin

After registering, manually update your role in MongoDB:

db.users.updateOne(
  { email: 'your@email.com' },
  { $set: { role: 'admin' } }
)

The "Admin" link will appear in the navigation after re-login.

Summary

FeatureHow It Works
SSR TemplatesBuilt-in AOT template engine with [= expr], [# if], [# each], [> include]
Layout Systemsettings.js → automatic layout wrapping + global variables
Block System[# block head]...content...[/block] for per-page CSS/JS
XSS ProtectionAll [= ...] auto-escaped; use raw() only for trusted HTML
AuthenticationCookie-based with defineGuard() + MongoDB sessions
AuthorizationRole-based guards: pipe: ['cookieAuth', 'admin']
CSRF ProtectionBuilt-in csrf pipe + [= raw(csrfField)] in forms
Flash Messagesrequest.flash() → displayed once in layout via [# if flashes.*]
DTO Validation['dto:loginDto'] pipe validates form data before controller
Services & DIauthService auto-discovered and injected into controllers
Static Filesstatic: './public' serves CSS with caching and ETag
Two Render APIsapp.render() for simple pages, request.render() in controllers
Next step — Read the SSR Templates guide for a deep dive into the template engine architecture, AOT compilation pipeline, and all available features.