SpaceNode
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.
app.render()cookieAuth guardcookieAuth + admin guards, displays user statistics| Component | Technology |
|---|---|
| Framework | SpaceNode (zero dependencies) |
| Template Engine | Built-in AOT compiler with XSS auto-escaping |
| Database | MongoDB + Mongoose |
| Password Hashing | bcrypt (salt rounds: 10) |
| Session Management | Cookie-based tokens stored in MongoDB |
| Configuration | dotenv (.env file) |
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
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)
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.This site uses two guards that form a pipeline chain:
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()).
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.
// 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.
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)
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)
The auth module handles all authentication flows: rendering login/register forms, processing form submissions with DTO validation, and managing sessions.
// 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 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.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.
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 })
},
}
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('/')
}
request.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).All templates are in the views/ directory. The engine auto-discovers settings.js to configure the layout and global variables.
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.
<!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>
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>
[# 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 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.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]
<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>
Here's what happens when a user visits /profile:
GET /profile → profile modulecookieAuth guard:
token from cookiesToken.findOne)User.findById){ user: ... } → merged into request.userprofilePage() runs:
request.render('pages/profile', { title: 'Profile', user: request.user })pages/profile.html to a JS function (cached){ title, user } with globals (siteName, year)[# block head] contentlayout.html with body = profile HTML + blockspartials/nav.html and partials/footer.htmlNode.js 18+ and MongoDB installed and running.
# examples/site/.env
MONGO_URI=mongodb://127.0.0.1:27017/spacenode-site
PORT=3000
SECRET=change-me-to-random-string
cd examples/site
npm install
npm start
Open http://localhost:3000 in your browser.
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.
| Feature | How It Works |
|---|---|
| SSR Templates | Built-in AOT template engine with [= expr], [# if], [# each], [> include] |
| Layout System | settings.js → automatic layout wrapping + global variables |
| Block System | [# block head]...content...[/block] for per-page CSS/JS |
| XSS Protection | All [= ...] auto-escaped; use raw() only for trusted HTML |
| Authentication | Cookie-based with defineGuard() + MongoDB sessions |
| Authorization | Role-based guards: pipe: ['cookieAuth', 'admin'] |
| CSRF Protection | Built-in csrf pipe + [= raw(csrfField)] in forms |
| Flash Messages | request.flash() → displayed once in layout via [# if flashes.*] |
| DTO Validation | ['dto:loginDto'] pipe validates form data before controller |
| Services & DI | authService auto-discovered and injected into controllers |
| Static Files | static: './public' serves CSS with caching and ETag |
| Two Render APIs | app.render() for simple pages, request.render() in controllers |