Skip to content

Project Structure

This page explains the standard directory structure generated by zzz new, where each file lives, and how the workspace model works when you use multiple zzz packages together.

Running zzz new my_app creates the following structure:

my_app/
build.zig
build.zig.zon
.gitignore
.env
.env.example
Dockerfile
docker-compose.yml
.dockerignore
src/
main.zig
controllers/
config/
config.zig
dev.zig
prod.zig
staging.zig
templates/
public/
css/
style.css
js/
app.js
FilePurpose
build.zigZig build script. Wires the zzz dependency, loads the config module selected by -Denv, and creates the executable.
build.zig.zonPackage manifest. Declares your project name, version, minimum Zig version, and dependencies (including the path to zzz.zig).
PathPurpose
src/main.zigApplication entry point. Defines the router, mounts middleware, and starts the server.
src/controllers/Route handler modules. The --full scaffold places home.zig and api.zig here.
PathPurpose
config/config.zigDefines the AppConfig struct shared across all environments. This is the single source of truth for your configuration shape.
config/dev.zigDevelopment defaults (host 127.0.0.1, port 4000). Selected by default when you run zig build run.
config/prod.zigProduction defaults (host 0.0.0.0, port 8080). Selected with zig build run -Denv=prod.
config/staging.zigStaging defaults. Production-like settings with the same bind address and port as prod.
.envRuntime environment variable overrides. Loaded automatically at startup and merged on top of the comptime config.
.env.exampleDocumented template of all supported environment variables. Committed to version control (.env itself is gitignored).
PathPurpose
public/Static files served by the staticFiles middleware. Contains css/ and js/ subdirectories by default.
templates/Template files for server-side rendering (used with the zzz_template package).
FilePurpose
DockerfileMulti-stage build that installs Zig, compiles a release binary, and produces a minimal Debian image.
docker-compose.ymlRuns the app container with health checks. When --db=postgres is used, it also includes PostgreSQL and Adminer services.
.dockerignorePrevents .env, build caches, and .git/ from being copied into the Docker image.

Pass --docker=false to zzz new to skip generating Docker files entirely.

The zzz new command accepts flags that change which files are generated:

FlagEffect
--fullAdds controller files (src/controllers/home.zig, src/controllers/api.zig) and uses a main.zig with session, CSRF, CORS, and static file middleware pre-configured.
--apiGenerates a JSON-only project. Skips the templates/ and public/ directories. Uses a minimal main.zig with no static file serving.
--db=sqliteAdds a database_url field to AppConfig and sets up .env with a SQLite connection string.
--db=postgresSame as sqlite but with a PostgreSQL connection URL, plus a docker-compose.yml that includes a Postgres service.
--docker=falseOmits Dockerfile, docker-compose.yml, and .dockerignore.

Example:

Terminal window
zzz new my_api --api --db=postgres --docker=false

Every zzz application starts in src/main.zig. The scaffolded entry point follows a standard pattern:

const std = @import("std");
const zzz = @import("zzz");
const app_config = @import("app_config");
const Router = zzz.Router;
const Context = zzz.Context;
fn index(ctx: *Context) !void {
ctx.html(.ok,
\\<!DOCTYPE html>
\\<html>
\\<head><title>Welcome to Zzz</title></head>
\\<body>
\\ <h1>Welcome to Zzz!</h1>
\\ <p>Your new project is ready.</p>
\\</body>
\\</html>
);
}
const App = Router.define(.{
.middleware = &.{
zzz.logger,
zzz.healthCheck(.{}),
},
.routes = &.{
Router.get("/", index),
},
});
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const io = init.io;
var env = try zzz.Env.init(allocator, .{});
defer env.deinit();
const config = zzz.mergeWithEnv(@TypeOf(app_config.config), app_config.config, &env);
var server = zzz.Server.init(allocator, .{
.host = config.host,
.port = config.port,
}, App.handler);
try server.listen(io);
}

The key steps are:

  1. Define routes and middleware using Router.define at comptime.
  2. Load environment variables with Env.init, which reads .env and system env vars.
  3. Merge config with mergeWithEnv, overlaying runtime env vars on top of the comptime defaults from config/dev.zig (or whichever environment was selected at build time).
  4. Create and start the server with Server.init and server.listen.

The build script uses -Denv to select which config file is compiled in:

const env_name = b.option([]const u8, "env", "Environment: dev (default), prod, staging") orelse "dev";
var config_path_buf: [64]u8 = undefined;
const config_path = std.fmt.bufPrint(&config_path_buf, "config/{s}.zig", .{env_name}) catch "config/dev.zig";

This means zig build run loads config/dev.zig, while zig build run -Denv=prod loads config/prod.zig. Each environment file imports the shared AppConfig struct from config/config.zig and provides its own defaults:

config/dev.zig
const AppConfig = @import("config").AppConfig;
pub const config: AppConfig = .{
.host = "127.0.0.1",
.port = 4000,
.secret_key_base = "dev-secret-not-for-production",
};

The selected config is exposed to your application code as the app_config import.

When using multiple zzz packages (database, jobs, mailer, templates), the recommended layout is a workspace directory containing sibling packages:

my_workspace/
zzz.zig/ # Core framework
zzz_db/ # Database package
zzz_jobs/ # Background jobs
zzz_mailer/ # Email sending
zzz_template/ # Template engine
my_app/ # Your application
build.zig
build.zig.zon
src/
config/
...

Your application’s build.zig.zon references sibling packages by relative path:

.dependencies = .{
.zzz = .{
.path = "../zzz.zig",
},
},

This approach means all packages share the same source tree, making it straightforward to develop against local changes. Consumer packages like zzz_jobs and zzz_mailer do not need their own linkSystemLibrary calls — symbols propagate through module dependencies automatically.