Pre-built Emacs configurations like Doom Emacs and Spacemacs are impressive. They give you a working, polished setup in minutes. But they are also someone else's opinion about how Emacs should work, and when something breaks — or when you want to understand what is actually happening — you are left reading hundreds of files written by strangers.
Starting from scratch is not as daunting as it sounds. You end up with a configuration you fully understand, can debug, and can extend without fear. This article walks through building a modern, minimal init.el using use-package for package configuration and straight.el as the package manager.
Why Not Just Use package.el?
Emacs ships with package.el, which installs packages from MELPA, ELPA, and other archives. It works, but it has real limitations for a reproducible setup. package.el installs the latest available version and does not lock versions. Two developers cloning your dotfiles may get different package versions, which can cause subtle breakage.
straight.el clones packages directly from Git and supports lockfiles via straight-freeze-versions. Every package is a Git repository, so you can pin to a commit, fork a package, or patch it locally. For a personal configuration that should behave identically across machines, this matters.
early-init.el: Cleaning Up Before Emacs Loads
Since Emacs 27, early-init.el is loaded before the graphical frame is created. This is the right place to disable UI elements so they never flash on screen, and to disable package.el so it does not interfere with straight.el.
;; early-init.el
;; Disable package.el — straight.el takes over
(setq package-enable-at-startup nil)
;; Disable UI chrome before the frame is drawn
(setq default-frame-alist
'((menu-bar-lines . 0)
(tool-bar-lines . 0)
(vertical-scroll-bars . nil)
(horizontal-scroll-bars . nil)))
;; Increase GC threshold during startup for speed
(setq gc-cons-threshold (* 128 1024 1024))
;; Reset GC threshold after startup
(add-hook 'emacs-startup-hook
(lambda ()
(setq gc-cons-threshold (* 8 1024 1024))))
Disabling the menu bar and tool bar in early-init.el rather than init.el eliminates the brief flash of toolbars that you see otherwise. The GC tweak is a common pattern: raise the garbage collection threshold to reduce pauses during init, then lower it back to a sane value afterwards.
Bootstrapping straight.el
straight.el does not ship with Emacs, so you need a bootstrap snippet that installs it on a fresh machine. Paste this at the top of your init.el:
;; Bootstrap straight.el
(defvar bootstrap-version)
(let ((bootstrap-file
(expand-file-name
"straight/repos/straight.el/bootstrap.el"
(or (bound-and-true-p straight-base-dir)
user-emacs-directory)))
(bootstrap-version 7))
(unless (file-exists-p bootstrap-file)
(with-current-buffer
(url-retrieve-synchronously
"https://raw.githubusercontent.com/radian-software/straight.el/develop/install.el"
'silent 'inhibit-cookies)
(goto-char (point-max))
(eval-print-last-sexp)))
(load bootstrap-file nil 'nomessage))
On the first run, this downloads and installs straight.el. On subsequent runs it simply loads the already-installed bootstrap file. The version number (7) should match the current stable release of straight.el.
Installing use-package via straight.el
Since Emacs 29, use-package is built into Emacs — you do not need to install it separately when using the default package.el. However, in this configuration we deliberately manage use-package via straight.el so that it is version-pinned alongside all other packages in the lockfile. This ensures the same version across every machine. Use :straight nil to opt out of this for built-in packages like eglot or flymake.
Once straight.el is bootstrapped, install use-package and tell straight.el to use it as the default installer:
;; Install use-package via straight
(straight-use-package 'use-package)
;; Make all use-package declarations use straight by default
(setq straight-use-package-by-default t)
With straight-use-package-by-default set to t, every use-package declaration automatically installs its package via straight.el. You do not need to add :straight t to every declaration, though you can still use :straight nil to opt out for built-in packages.
The no-littering Package: Taming ~/.emacs.d
By default, Emacs and various packages scatter files across ~/.emacs.d/: backup files, auto-save files, undo history, custom.el writes, and dozens of cache files from third-party packages. The no-littering package redirects all of these into two canonical directories: ~/.emacs.d/var/ for variable data and ~/.emacs.d/etc/ for configuration data.
(use-package no-littering
:config
;; Keep auto-save files out of the way
(setq auto-save-file-name-transforms
`((".*" ,(no-littering-expand-var-file-name "auto-save/") t)))
;; Keep the custom file separate from init.el
(setq custom-file (no-littering-expand-etc-file-name "custom.el"))
(when (file-exists-p custom-file)
(load custom-file)))
Install no-littering early in your init.el, before other packages, so it can intercept file paths from the start.
Core use-package Declarations
The power of use-package is that each package declaration is self-contained: it specifies what to install, when to load it, how to configure it, and what keybindings to set. Here are the key keywords:
:init— code that runs before the package loads:config— code that runs after the package loads:hook— attaches the package to Emacs hooks:bind— sets keybindings and autoloads the package:defer t— defers loading until needed (speeds up startup):demand t— forces immediate loading:custom— sets custom variables viacustomize-set-variable
A Practical Package Set
vertico: Vertical Completion UI
(use-package vertico
:init
(vertico-mode))
(use-package vertico-directory
:straight nil ; part of vertico
:after vertico
:bind (:map vertico-map
("RET" . vertico-directory-enter)
("DEL" . vertico-directory-delete-char)
("M-DEL" . vertico-directory-delete-word)))
orderless: Flexible Completion Matching
(use-package orderless
:custom
(completion-styles '(orderless basic))
(completion-category-overrides
'((file (styles basic partial-completion)))))
Orderless lets you type completion components in any order, separated by spaces. Typing buf swi matches switch-to-buffer. It is a fundamental upgrade to the Emacs completion experience.
marginalia: Rich Completion Annotations
(use-package marginalia
:init
(marginalia-mode))
corfu: In-Buffer Completion Popup
(use-package corfu
:custom
(corfu-auto t)
(corfu-auto-delay 0.2)
(corfu-auto-prefix 2)
(corfu-quit-no-match 'separator)
:init
(global-corfu-mode))
(use-package cape
:init
;; Add useful completion-at-point functions
(add-hook 'completion-at-point-functions #'cape-dabbrev)
(add-hook 'completion-at-point-functions #'cape-file))
magit
(use-package magit
:bind ("C-x g" . magit-status))
eglot: Built-in LSP Client
(use-package eglot
:straight nil ; built into Emacs 29+
:hook ((php-mode . eglot-ensure)
(js-mode . eglot-ensure))
:config
(add-to-list 'eglot-server-programs
'(php-mode . ("intelephense" "--stdio"))))
A Complete ~50-Line init.el Skeleton
;; init.el — minimal modern Emacs configuration
;;; Bootstrap straight.el
(defvar bootstrap-version)
(let ((bootstrap-file
(expand-file-name
"straight/repos/straight.el/bootstrap.el"
user-emacs-directory))
(bootstrap-version 7))
(unless (file-exists-p bootstrap-file)
(with-current-buffer
(url-retrieve-synchronously
"https://raw.githubusercontent.com/radian-software/straight.el/develop/install.el"
'silent 'inhibit-cookies)
(goto-char (point-max))
(eval-print-last-sexp)))
(load bootstrap-file nil 'nomessage))
;;; use-package
(straight-use-package 'use-package)
(setq straight-use-package-by-default t)
;;; Keep ~/.emacs.d clean
(use-package no-littering
:config
(setq auto-save-file-name-transforms
`((".*" ,(no-littering-expand-var-file-name "auto-save/") t)))
(setq custom-file (no-littering-expand-etc-file-name "custom.el"))
(when (file-exists-p custom-file) (load custom-file)))
;;; Defaults
(setq-default
indent-tabs-mode nil
fill-column 80)
(setq inhibit-startup-message t
ring-bell-function 'ignore
make-backup-files nil)
(global-set-key (kbd "C-x C-b") 'ibuffer)
;;; Completion
(use-package vertico :init (vertico-mode))
(use-package orderless
:custom (completion-styles '(orderless basic)))
(use-package marginalia :init (marginalia-mode))
(use-package corfu
:custom (corfu-auto t)
:init (global-corfu-mode))
;;; Git
(use-package magit
:bind ("C-x g" . magit-status))
;;; LSP
(use-package eglot
:straight nil
:hook (php-mode . eglot-ensure))
;;; Theme
(use-package modus-themes
:config (load-theme 'modus-vivendi t))
Common Pitfalls
Load Order Matters
Packages that other packages depend on must be declared earlier. no-littering must come before anything that creates files. orderless must be declared before packages that rely on completion-styles. If something fails during startup, --debug-init on the command line gives you a backtrace: emacs --debug-init.
Byte Compilation Surprises
straight.el byte-compiles packages for speed, which is usually good. But sometimes a package's compiled version becomes stale after an update. Run M-x straight-rebuild-all to force recompilation of everything. If a specific package is misbehaving, M-x straight-rebuild-package targets just that one.
:init vs :config
A subtle bug appears when you put code in :config that needs to run before the package loads. The :init block runs unconditionally, before the package is loaded. The :config block runs only after the package is loaded (and only if the package was actually loaded). Enabling a global minor mode like (vertico-mode) belongs in :init because it is what triggers the load.
straight.el and Lockfiles
To make your configuration reproducible, run M-x straight-freeze-versions after a stable setup. This writes a straight/versions/default.el lockfile that pins every package to a specific commit. Commit this file to your dotfiles repository. On a new machine, run M-x straight-thaw-versions after bootstrapping to restore exact versions.
The :straight nil Pattern
Emacs 29+ ships with eglot, treesit, project, and flymake built in. Use :straight nil for these so straight.el does not try to download and override them with MELPA versions. You still get use-package's configuration syntax without the package management.
Further Reading Within Your Config
Once this skeleton is working, the natural next steps are adding consult for enhanced search and navigation, embark for contextual actions on completion candidates, and language-specific modes for PHP, TypeScript, or whatever you work with. Each addition follows the same use-package pattern, keeping your configuration readable and incremental.
The key advantage of this approach over Doom or Spacemacs is that every line in your init.el is a line you wrote or deliberately chose. When Emacs behaves unexpectedly, you know exactly where to look.