A lightweight, self-hosted web application for curating and managing your frequently visited links. Built with vanilla JavaScript, Node.js, and Tailwind CSS.
- ✨ Beautiful Dashboard - Choose between grid, list, or cards layout
- 🔐 Secure Authentication - Password-hashed login with session management
- 🎨 Customizable Themes - Dark/light mode with 8 accent colors and 20 background colors
- 🔍 Search & Filter - Quickly find links as your collection grows
- 🏷️ Tags - Organize links with custom colored tags and filter by tag
- 🎯 Drag & Drop - Reorder links easily with visual feedback
- 🎭 Icon Picker - Choose from Material Icons, Font Awesome, or upload custom icons
- 💾 Import/Export - Backup and restore your links in JSON format
- 🐳 Docker Ready - Easy deployment with Docker or docker-compose
- 🪶 Lightweight - Minimal dependencies, fast and simple
- 🎭 Custom Page Titles - Personalize your dashboard title
- 🌐 Auto Favicons - Automatically fetches favicons for your links
- 🔀 Reverse Proxy Support - Built-in BASE_PATH support for serving from subpaths
- 📱 PWA Support - Install as an app on mobile devices ("Add to Home Screen")
- 🛡️ Security Hardening - Rate limiting and CSRF protection
- ⌨️ Keyboard Shortcuts - Press
/to quickly focus search
# Install dependencies
npm install
# Build CSS
npm run build:css
# Start server
npm start
# Visit http://localhost:3000For development with auto-rebuild:
# Watch CSS and start server
npm run watch:css &
npm start
# Or use the dev command (builds CSS then starts)
npm run dev# Pull and run the latest image
docker run -d -p 3000:3000 -v ./data:/data jparkerweb/simple-linkz:latest
# Run with custom port and session secret
docker run -d -p 8080:8080 \
-e PORT=8080 \
-e SESSION_SECRET=your-secret-key \
-v ./data:/data \
jparkerweb/simple-linkz:latest
# Run behind reverse proxy at a subpath (e.g., yourdomain.com/simple-linkz)
docker run -d -p 3000:3000 \
-e BASE_PATH=/simple-linkz \
-v ./data:/data \
jparkerweb/simple-linkz:latest
# Or build your own image
docker build -t simple-linkz .
docker run -d -p 3000:3000 -v ./data:/data simple-linkz# Start in background
docker-compose up -d
# View logs
docker-compose logs -f
# Stop
docker-compose down- On first visit, you'll be prompted to create a username and password
- Your credentials are securely hashed and stored in
/data/data.json - All your links and preferences are stored in the same file
If you need to reset your username or password:
- Stop the application
- Open
data/data.json - Delete the
"user"section (the entire object with username and passwordHash) - Save the file
- Restart the application
- You'll be prompted to create new credentials
PORT- Server port (default:3000)SESSION_SECRET- Custom session secret (auto-generated if not provided)DATA_DIR- Custom data directory path (default:./data)BASE_PATH- Base URL path for reverse proxy subpath serving (default: empty/root)- Allows serving from a subpath like Sonarr/Radarr URL base configuration
- Example:
BASE_PATH=/simple-linkzserves atyourdomain.com/simple-linkz - Leave empty (default) to serve from root:
yourdomain.com/ - Important: Configure this when using reverse proxy custom locations/subpaths
- The server automatically handles path stripping and asset URL rewriting
All data is stored in a single JSON file at ./data/data.json:
- User credentials (hashed password)
- Links (name, URL, order, favicon)
- Preferences (layout, theme, accent color)
- Active sessions
Important: Exclude /data from version control. Add it to your volume mount for Docker.
- Frontend: HTML5, Vanilla JavaScript, Tailwind CSS v3
- Backend: Node.js (native HTTP server)
- Storage: JSON file
- Authentication: bcryptjs, signed cookies
- Container: Docker (Node 20 Alpine)
simple-linkz/
├── data/ # Data storage (git-ignored)
│ └── data.json # All app data
├── public/ # Frontend files
│ ├── index.html
│ ├── app.js
│ └── styles.css
├── src/ # Backend modules
│ ├── server.js # HTTP server
│ ├── storage.js # Data persistence
│ ├── auth.js # Authentication
│ ├── api.js # API endpoints
│ └── input.css # Tailwind source
├── Dockerfile
├── docker-compose.yml
└── package.json
Adding a Link:
- Click the "+" button in the header
- Enter a name (emoji support: 📚, 🎮, etc.)
- Enter the URL (automatically adds https:// if missing)
- Click "Add Link" - favicon is fetched automatically
Editing a Link:
- Click the edit icon on any link card
- Modify name or URL
- Click "Save Changes"
Deleting a Link:
- Click the edit icon on the link
- Click "Delete Link"
- Confirm deletion
Reordering Links:
- Drag any link card to a new position
- Drop it where you want it
- Order is saved automatically
Layouts:
- Grid: Uniform card grid (default)
- List: Compact list view
- Cards: Larger cards with more spacing
Themes:
- Light: Clean white background
- Dark: Dark gray/black background
Accent Colors:
- Blue (default)
- Green
- Purple
- Red
- Orange
Background Colors: Each theme has specific background options:
- Light theme: White, Gray, Slate, Zinc
- Dark theme: Dark, Gray variants
Page Title: Click "Edit Title" in settings to customize the dashboard header.
Export Links:
- Open Settings
- Click "Export Data"
- Save the JSON file to your device
Import Links:
- Open Settings
- Click "Import Data"
- Select your backup JSON file
- Choose merge (add to existing) or replace (overwrite all)
Manual Backup:
Copy the entire /data/data.json file to a safe location.
All API endpoints require authentication via session cookie (except /api/setup and /api/login).
Setup User (First Time)
POST /api/setup
Content-Type: application/json
{
"username": "admin",
"password": "your-secure-password"
}
Response: 200 OK
{
"success": true
}Login
POST /api/login
Content-Type: application/json
{
"username": "admin",
"password": "your-password"
}
Response: 200 OK
Set-Cookie: session=signed-token; HttpOnly; SameSite=Strict
{
"success": true
}Logout
POST /api/logout
Response: 200 OK
{
"success": true
}Check Setup Status
GET /api/setup
Response: 200 OK
{
"needsSetup": true|false
}Get All Links
GET /api/links
Response: 200 OK
[
{
"id": "uuid",
"name": "Example",
"url": "https://example.com",
"order": 0,
"faviconUrl": "https://example.com/favicon.ico"
}
]Add Link
POST /api/links
Content-Type: application/json
{
"name": "GitHub",
"url": "https://github.com"
}
Response: 201 Created
{
"id": "uuid",
"name": "GitHub",
"url": "https://github.com",
"order": 0,
"faviconUrl": "https://github.com/favicon.ico"
}Update Link
PUT /api/links/:id
Content-Type: application/json
{
"name": "Updated Name",
"url": "https://newurl.com"
}
Response: 200 OK
{
"id": "uuid",
"name": "Updated Name",
"url": "https://newurl.com",
"order": 0,
"faviconUrl": "https://newurl.com/favicon.ico"
}Delete Link
DELETE /api/links/:id
Response: 200 OK
{
"success": true
}Reorder Links
PUT /api/links/reorder
Content-Type: application/json
{
"linkIds": ["uuid1", "uuid2", "uuid3"]
}
Response: 200 OK
{
"success": true
}Get Preferences
GET /api/preferences
Response: 200 OK
{
"layout": "grid",
"theme": "light",
"accentColor": "blue",
"backgroundColor": "white",
"pageTitle": "Simple Linkz"
}Update Preferences
PUT /api/preferences
Content-Type: application/json
{
"layout": "cards",
"theme": "dark",
"accentColor": "purple",
"backgroundColor": "dark",
"pageTitle": "My Links"
}
Response: 200 OK
{
"layout": "cards",
"theme": "dark",
"accentColor": "purple",
"backgroundColor": "dark",
"pageTitle": "My Links"
}Export Data
GET /api/export
Response: 200 OK
Content-Type: application/json
{
"links": [...],
"preferences": {...},
"exportDate": "2025-01-15T12:00:00.000Z",
"version": "1.0"
}Import Data
POST /api/import
Content-Type: application/json
{
"links": [...],
"preferences": {...},
"merge": true|false
}
Response: 200 OK
{
"success": true,
"imported": {
"links": 5,
"preferences": true
}
}Fetch Favicon
GET /api/favicon?url=https://example.com
Response: 200 OK
Content-Type: image/*
(binary image data)Note: All tag endpoints require
X-CSRF-Tokenheader for mutations.
Get All Tags
GET /api/tags
Response: 200 OK
{
"tags": [
{ "id": "uuid", "name": "Work", "color": "#3B82F6" }
]
}Create Tag
POST /api/tags
Content-Type: application/json
X-CSRF-Token: your-csrf-token
{
"name": "Work",
"color": "#3B82F6"
}
Response: 201 Created
{
"tag": { "id": "uuid", "name": "Work", "color": "#3B82F6" }
}Update Tag
PUT /api/tags/:id
Content-Type: application/json
X-CSRF-Token: your-csrf-token
{
"name": "Updated Name",
"color": "#10B981"
}
Response: 200 OK
{
"tag": { "id": "uuid", "name": "Updated Name", "color": "#10B981" }
}Delete Tag
DELETE /api/tags/:id
X-CSRF-Token: your-csrf-token
Response: 200 OK
{
"success": true
}Bulk Tag Operation
POST /api/links/bulk-tag
Content-Type: application/json
X-CSRF-Token: your-csrf-token
{
"linkIds": ["uuid1", "uuid2"],
"operation": "add|remove",
"tagIds": ["tag-uuid1", "tag-uuid2"]
}
Response: 200 OK
{
"success": true
}Get Custom Icons
GET /api/icons
Response: 200 OK
{
"icons": [
{ "id": "uuid", "filename": "icon.png", "uploadedAt": 1234567890 }
]
}Upload Custom Icon
POST /api/icons
Content-Type: multipart/form-data
X-CSRF-Token: your-csrf-token
(file upload, max 100KB, PNG/SVG/ICO/WEBP)
Response: 201 Created
{
"icon": { "id": "uuid", "filename": "uuid.png", "uploadedAt": 1234567890 }
}Delete Custom Icon
DELETE /api/icons/:id
X-CSRF-Token: your-csrf-token
Response: 200 OK
{
"success": true
}Get CSRF Token
GET /api/csrf
Response: 200 OK
{
"csrfToken": "hex-string"
}Important: Include this token in the
X-CSRF-Tokenheader for all POST/PUT/DELETE requests.
Simple Linkz uses a modular backend architecture with native Node.js (no Express):
server.js - HTTP server entry point
- Routes static files from
/public - Routes API requests to
api.js - Configurable port via
PORTenvironment variable - Handles
BASE_PATHprefix stripping and asset URL rewriting
storage.js - Data persistence layer
- Single JSON file storage (
/data/data.json) - Atomic writes using temp file + rename pattern
- Auto-initialization with sensible defaults
- Thread-safe operations
auth.js - Authentication & sessions
- Bcrypt password hashing (10 rounds)
- HMAC-SHA256 signed cookies
- 7-day session expiration
- Automatic session cleanup
api.js - RESTful API endpoints
- Request routing and parsing
- Authentication middleware
- JSON response handling
- Error handling
Single Page Application (SPA)
- Vanilla JavaScript (no framework)
- State management with reactive updates
- Modal-based UI interactions
- Client-side routing for auth states
State Management:
{
links: [], // Array of link objects
preferences: {}, // User preferences
searchQuery: '', // Current search filter
editingLink: null // Link being edited (or null)
}Key Functions:
renderLinks()- Dynamic rendering based on layoutapplyTheme()- CSS custom properties for themingsaveLink()- Create/update link with validationdeleteLink()- Remove link with confirmationreorderLinks()- Handle drag-and-drop
{
"schemaVersion": 1,
"sessionSecret": "hex-string",
"user": {
"username": "string",
"passwordHash": "bcrypt-hash"
},
"preferences": {
"layout": "grid|list|cards",
"theme": "light|dark",
"accentColor": "blue|green|purple|red|orange|pink|cyan|yellow",
"backgroundColor": "white|stone|slate|sky|cyan|mint|lime|cream|peach|rose|charcoal|graphite|navy|ocean|forest|olive|espresso|burgundy|plum|noir",
"pageTitle": "string",
"customCss": {
"borderRadius": "0.5rem",
"cardShadow": "0 4px 6px...",
"fontFamily": "system-ui",
"linkGap": "1rem",
"widgetPadding": "1rem"
}
},
"links": [
{
"id": "uuid-v4",
"name": "string",
"url": "string",
"order": "number",
"faviconUrl": "string",
"tags": ["tag-id-1", "tag-id-2"],
"iconType": "favicon|material|fontawesome|custom",
"iconValue": "icon-id-or-filename"
}
],
"tags": [
{
"id": "uuid-v4",
"name": "string",
"color": "#hex"
}
],
"customIcons": [
{
"id": "uuid-v4",
"filename": "icon.png",
"uploadedAt": "timestamp"
}
],
"sessions": {
"token": {
"createdAt": "timestamp",
"expiresAt": "timestamp"
}
},
"csrfTokens": {
"session-token": "csrf-token"
},
"rateLimiting": {
"attempts": { "ip": ["timestamp1", "timestamp2"] },
"blocked": { "ip": { "until": "timestamp", "blockCount": "number" } }
}
}- Password Hashing: bcrypt with 10 salt rounds
- Session Management: Signed cookies with HMAC-SHA256
- Session Expiration: 7 days, validated on each request
- Cookie Security: HttpOnly, SameSite=Strict flags
- Failed Login Protection: Blocks IP after 5 failed attempts in 15 minutes
- Exponential Backoff: Block duration doubles on repeated offenses (15min → 30min → 60min → ...)
- Proxy Support: Respects
X-Forwarded-Forheader for correct IP detection
- Token-Based: All mutating requests (POST/PUT/DELETE) require
X-CSRF-Tokenheader - Session-Bound: CSRF tokens are tied to session tokens
- Auto-Refresh: Frontend automatically refetches token on 403 errors
- Single-user design (no multi-user support)
- Auto-generated session secrets
- No sensitive data in client-side storage
- Atomic file writes prevent data corruption
- Input validation on all API endpoints
- Run behind reverse proxy (nginx, Caddy, Nginx Proxy Manager)
- Use HTTPS in production
- Set custom
SESSION_SECRETenvironment variable - Configure
BASE_PATHif serving from a subpath (e.g.,/simple-linkz) - Regular backups of
/data/data.json - Keep dependencies updated
Nginx Proxy Manager (Custom Locations):
- Create proxy host for your domain
- Add custom location (e.g.,
/simple-linkz) - Forward to:
http://container-name:3000 - Set container environment:
BASE_PATH=/simple-linkz - No additional nginx configuration needed
Nginx (Manual Configuration):
location /simple-linkz {
proxy_pass http://simple-linkz:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}Then set BASE_PATH=/simple-linkz in container environment.
Subdomain (Recommended Alternative): If you don't need subpath serving, use a subdomain instead:
simple-linkz.yourdomain.com→ No BASE_PATH needed- Simpler configuration, no path prefix handling
Server won't start
# Check if port is in use
netstat -ano | findstr :3000 # Windows
lsof -i :3000 # Linux/Mac
# Use different port
PORT=8080 npm startCSS not updating
# Rebuild Tailwind CSS
npm run build:css
# For development, use watch mode
npm run watch:cssCan't login after password reset
- Ensure you deleted the entire
"user"object fromdata.json - Check that
data.jsonis valid JSON - Restart the server completely
Links not saving
- Check file permissions on
/datadirectory - Verify disk space is available
- Check server logs for errors
Favicons not loading
- Some sites block favicon requests
- Check if the site's favicon URL is accessible
- Try manually setting a favicon URL
Import fails
- Ensure JSON file is valid
- Check that file contains
linksarray - Verify file size is reasonable (<10MB)
Reverse proxy subpath not working
- Set
BASE_PATHenvironment variable (e.g.,BASE_PATH=/simple-linkz) - Ensure reverse proxy forwards the full path including prefix
- Check container logs for incoming request paths
- Verify reverse proxy points to correct container name and port
Volume mount not working
# Use absolute path
docker run -v /full/path/to/data:/data simple-linkz
# Check volume
docker inspect <container-id>Permission errors
# Fix data directory permissions
chmod 755 ./data
chown -R 1000:1000 ./data # Match container userEnable detailed logging:
// Add to server.js
process.env.DEBUG = 'true';simple-linkz/
├── .github/ # GitHub workflows (if any)
├── data/ # Data storage (git-ignored)
│ └── data.json # All app data
├── public/ # Frontend files
│ ├── index.html # SPA shell
│ ├── app.js # Application logic
│ └── styles.css # Generated Tailwind CSS
├── src/ # Backend modules
│ ├── server.js # HTTP server
│ ├── storage.js # Data persistence
│ ├── auth.js # Authentication
│ ├── api.js # API endpoints
│ └── input.css # Tailwind source
├── .dockerignore # Docker ignore patterns
├── .gitignore # Git ignore patterns
├── Dockerfile # Docker image definition
├── docker-compose.yml # Docker compose config
├── package.json # Node dependencies
├── package-lock.json # Locked dependencies
└── tailwind.config.js # Tailwind configuration
Frontend Changes:
- Edit
public/app.jsorpublic/index.html - Refresh browser to see changes
- No build step required for JS/HTML
CSS Changes:
- Edit
src/input.css - Run
npm run build:css - Refresh browser
Backend Changes:
- Edit files in
src/ - Restart server:
npm start - Test API endpoints
New API Endpoint:
- Add route handler in
src/api.js - Add authentication check if needed
- Use storage functions from
storage.js - Return JSON response
New Storage Fields:
- Update schema in
storage.js - Add getter/setter functions
- Update
initializeData()defaults - Migrate existing data if needed
New UI Component:
- Add HTML to
public/index.html - Add logic to
public/app.js - Add Tailwind classes for styling
- Update state management if needed
Manual Testing Checklist:
- Setup flow (first-time user creation)
- Login/logout
- Add/edit/delete links
- Drag-and-drop reordering
- Search functionality
- All three layouts (grid/list/cards)
- Theme switching
- Import/export
- Password reset flow
- Mobile responsiveness
Browser Testing:
- Chrome/Edge (Chromium)
- Firefox
- Safari (if available)
Contributions are welcome! Please follow these guidelines:
- Fork the repository
- Create a feature branch
- Make your changes
- Test thoroughly
- Submit a pull request
Code Style:
- Use ES6+ features
- 2-space indentation
- Semicolons required
- Descriptive variable names
- Comments for complex logic
MIT
Copyright (c) 2025
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Built with:
- Node.js - Runtime environment
- Tailwind CSS - Utility-first CSS framework
- bcryptjs - Password hashing
- uuid - Unique ID generation
Inspired by the need for a simple, self-hosted link manager without the bloat of larger bookmark management tools.



